批量更新
This commit is contained in:
@@ -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>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { convertMenusToRoutes } from "./dynamicRoutes";
|
||||
import { isSSOEnabled, redirectToAuthorize } from "@/utils/authClient";
|
||||
import { isSSOEnabled, redirectToAuthorize, ensureUserInfo } from "@/utils/authClient";
|
||||
|
||||
// 静态子路由:需要在 Main 框架内显示的页面
|
||||
const staticMainChildren = [
|
||||
@@ -336,6 +336,17 @@ router.beforeEach(async (to, from, next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一认证模式:补全用户信息(旧会话可能只有 token 没有 userInfo,
|
||||
// 或迁移后 userInfo 缺失 id,会导致菜单等接口报「用户ID不存在」)。
|
||||
// 令牌失效时 ensureUserInfo 会清空登录态并返回 null,此时重新登录。
|
||||
if (isSSOEnabled()) {
|
||||
const info = await ensureUserInfo();
|
||||
if (!info) {
|
||||
await redirectToAuthorize();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dynamicRoutesAdded) {
|
||||
await loadAndAddDynamicRoutes();
|
||||
// 路由加载后重新导航,确保路由匹配正确
|
||||
|
||||
@@ -171,6 +171,48 @@ export async function logoutSSO() {
|
||||
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() {
|
||||
const res = await fetch(`${AUTH_BASE}/userinfo`, {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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,添加调试信息
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
|
||||
@@ -56,10 +59,14 @@ service.interceptors.response.use(
|
||||
error.config.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
return service.request(error.config);
|
||||
} catch (e) {
|
||||
// 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环)
|
||||
clearTokens();
|
||||
localStorage.removeItem('userInfo');
|
||||
window.location.href = '#/login';
|
||||
return Promise.reject(new Error('token无效'));
|
||||
if (!ssoRedirecting) {
|
||||
ssoRedirecting = true;
|
||||
redirectToAuthorize();
|
||||
}
|
||||
return Promise.reject(new Error('token无效,请重新登录'));
|
||||
}
|
||||
}
|
||||
console.error('未授权,请重新登录');
|
||||
|
||||
@@ -327,6 +327,14 @@ const previewList = computed(() =>
|
||||
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 remarkSaving = ref(false);
|
||||
@@ -374,10 +382,7 @@ async function saveRemark() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 绑定提醒的状态回显与反馈 ----------
|
||||
const reminderInfo = ref(null);
|
||||
const reminderLoading = ref(false);
|
||||
|
||||
// ---------- 提醒渠道文案映射 ----------
|
||||
const channelTextMap = {
|
||||
SMS: "短信",
|
||||
EMAIL: "邮件",
|
||||
|
||||
@@ -16,10 +16,39 @@
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { handleAuthorizeCallback, redirectToAuthorize } from "@/utils/authClient";
|
||||
import {
|
||||
handleAuthorizeCallback,
|
||||
redirectToAuthorize,
|
||||
fetchUserInfo,
|
||||
} from "@/utils/authClient";
|
||||
|
||||
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() {
|
||||
// hash 路由:参数位于 location.hash 之后,这里统一从 URL 中取出
|
||||
const raw = window.location.href;
|
||||
@@ -28,6 +57,7 @@ async function finish() {
|
||||
|
||||
try {
|
||||
await handleAuthorizeCallback(query);
|
||||
await saveUserInfo();
|
||||
// 登录成功:清掉 URL 上的 code/state,进入首页
|
||||
window.location.href = "#/home";
|
||||
window.location.reload();
|
||||
|
||||
@@ -23,7 +23,9 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 4001,
|
||||
port: 4401,
|
||||
// 端口被占用时直接报错,避免自动漂移导致统一登录回跳地址不匹配
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:9000',
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
authsvc "server/services/auth"
|
||||
@@ -200,20 +201,33 @@ func (c *AuthLoginController) LogoutPage() {
|
||||
if sid != "" {
|
||||
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
|
||||
}
|
||||
// 走统一登出入口:吊销令牌并通知其他应用(单点登出)
|
||||
if access != "" {
|
||||
_ = authsvc.RevokeTokenPair("", access, models.RevokeReasonLogout)
|
||||
_ = authsvc.Logout(access, "")
|
||||
}
|
||||
clearSessionCookieForCtx(c.Ctx)
|
||||
|
||||
back := strings.TrimSpace(c.GetString("post_logout_redirect_uri"))
|
||||
cid := strings.TrimSpace(c.GetString("client_id"))
|
||||
if back != "" && cid != "" {
|
||||
if client, err := findClient(cid); err == nil && allowRedirect(client, back) {
|
||||
if client, err := findClient(cid); err == nil && allowLogoutRedirect(client, back) {
|
||||
c.Redirect(back, 302)
|
||||
return
|
||||
}
|
||||
// 回跳地址未登记时不要直接抛 JSON 给用户,退回登录页
|
||||
target := "/auth/login?error=" + url.QueryEscape("登出回跳地址未登记,已返回登录页")
|
||||
if cid != "" {
|
||||
target += "&client_id=" + url.QueryEscape(cid)
|
||||
}
|
||||
c.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 在线设备列表
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -262,10 +263,36 @@ func (c *AuthOidcController) UserInfo() {
|
||||
c.fail(500, "server_error")
|
||||
return
|
||||
}
|
||||
|
||||
// 业务接口与前端统一使用 identity_id 作为用户标识
|
||||
account, name, groupID := "", "", uint64(0)
|
||||
if bind, err := authsvc.GetTenantUser(identity.ID, uint64(claims.TenantId)); err == nil {
|
||||
groupID = bind.GroupID
|
||||
if bind.Account != nil {
|
||||
account = *bind.Account
|
||||
}
|
||||
if bind.Name != nil {
|
||||
name = *bind.Name
|
||||
}
|
||||
}
|
||||
if account == "" {
|
||||
account = profile.Mobile
|
||||
}
|
||||
if name == "" {
|
||||
name = profile.Nickname
|
||||
}
|
||||
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"sub": fmt.Sprintf("%d", identity.ID),
|
||||
"id": identity.ID,
|
||||
"union_id": identity.UnionID,
|
||||
"tid": claims.TenantId,
|
||||
// 诊断用:业务接口按 user_type 判定权限(backend / app),
|
||||
// 出现「无权访问」时可先看这里的值是否正确
|
||||
"user_type": claims.UserType,
|
||||
"group_id": groupID,
|
||||
"account": account,
|
||||
"name": name,
|
||||
"nickname": profile.Nickname,
|
||||
"mobile": profile.Mobile,
|
||||
"email": profile.Email,
|
||||
@@ -397,6 +424,46 @@ func allowRedirect(client *models.AuthClient, uri string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// allowLogoutRedirect 登出回跳地址校验。
|
||||
//
|
||||
// 先精确匹配白名单,再按 origin(协议+主机+端口)放宽匹配:
|
||||
// 实际使用中「末尾斜杠」「带 #/login 片段」等差异很常见,
|
||||
// 只做精确匹配会导致明明同域却跳不回去,因此同域即放行。
|
||||
func allowLogoutRedirect(client *models.AuthClient, uri string) bool {
|
||||
raw := ""
|
||||
if client.PostLogoutURIs != nil {
|
||||
raw = *client.PostLogoutURIs
|
||||
}
|
||||
var list []string
|
||||
if raw != "" {
|
||||
_ = json.Unmarshal([]byte(raw), &list)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return false
|
||||
}
|
||||
target := strings.TrimSpace(uri)
|
||||
for _, item := range list {
|
||||
if strings.TrimSpace(item) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
targetURL, err := url.Parse(target)
|
||||
if err != nil || targetURL.Scheme == "" || targetURL.Host == "" {
|
||||
return false
|
||||
}
|
||||
for _, item := range list {
|
||||
base, err := url.Parse(strings.TrimSpace(item))
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
continue
|
||||
}
|
||||
if base.Scheme == targetURL.Scheme && base.Host == targetURL.Host {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// issueAuthCode 生成一次性授权码(明文返回,库中只存哈希)
|
||||
func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) {
|
||||
plain, err := randomString(32)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// AuthSessionController 会话管理(用户自助:查看在线设备、踢下线)
|
||||
type AuthSessionController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *AuthSessionController) serveJSON(data map[string]interface{}) {
|
||||
c.Data["json"] = data
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// DevicesPage 在线设备页(浏览器访问,依赖认证中心会话 Cookie)
|
||||
// GET /auth/devices
|
||||
func (c *AuthSessionController) DevicesPage() {
|
||||
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||
if sid == "" {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
session, err := authsvc.GetSession(sid)
|
||||
if err != nil {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
|
||||
list, err := authsvc.ListActiveSessions(session.IdentityID)
|
||||
if err != nil {
|
||||
list = nil
|
||||
}
|
||||
|
||||
items := make([]map[string]interface{}, 0, len(list))
|
||||
now := time.Now()
|
||||
for _, s := range list {
|
||||
device := "未知设备"
|
||||
if s.DeviceName != nil && strings.TrimSpace(*s.DeviceName) != "" {
|
||||
device = *s.DeviceName
|
||||
}
|
||||
ip := "-"
|
||||
if s.IP != nil {
|
||||
ip = *s.IP
|
||||
}
|
||||
client := s.ClientID
|
||||
if client == "" {
|
||||
client = "-"
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"Sid": s.Sid,
|
||||
"Current": s.Sid == sid,
|
||||
"Device": device,
|
||||
"IP": ip,
|
||||
"Client": client,
|
||||
"LoginAt": s.LoginAt.Format("2006-01-02 15:04"),
|
||||
"LastAt": s.LastAccessAt.Format("2006-01-02 15:04"),
|
||||
"Expired": s.ExpiresAt.Before(now),
|
||||
"Tid": s.Tid,
|
||||
})
|
||||
}
|
||||
|
||||
c.Data["Sessions"] = items
|
||||
c.Data["Success"] = c.GetString("success")
|
||||
c.Data["Error"] = c.GetString("error")
|
||||
c.TplName = "auth/devices.tpl"
|
||||
}
|
||||
|
||||
// KickPage 踢下线指定设备(页面入口,踢完后回到设备页)
|
||||
// GET /auth/devices/kick?sid=
|
||||
func (c *AuthSessionController) KickPage() {
|
||||
target := strings.TrimSpace(c.GetString("sid"))
|
||||
current := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||
if current == "" {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
session, err := authsvc.GetSession(current)
|
||||
if err != nil {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
if target == "" {
|
||||
c.Redirect("/auth/devices?error="+url.QueryEscape("参数错误"), 302)
|
||||
return
|
||||
}
|
||||
|
||||
// 只允许操作自己名下的会话
|
||||
owned := false
|
||||
if list, e := authsvc.ListActiveSessions(session.IdentityID); e == nil {
|
||||
for _, s := range list {
|
||||
if s.Sid == target {
|
||||
owned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !owned {
|
||||
c.Redirect("/auth/devices?error="+url.QueryEscape("无权操作该设备"), 302)
|
||||
return
|
||||
}
|
||||
if target == current {
|
||||
c.Redirect("/auth/devices?error="+url.QueryEscape("不能踢掉当前设备,请直接登出"), 302)
|
||||
return
|
||||
}
|
||||
|
||||
if err := authsvc.RevokeSession(target, models.RevokeReasonAdmin); err != nil {
|
||||
c.Redirect("/auth/devices?error="+url.QueryEscape("操作失败"), 302)
|
||||
return
|
||||
}
|
||||
// 同步吊销该会话的刷新令牌,确保对方无法续期
|
||||
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||
Filter("sid", target).
|
||||
Update(map[string]interface{}{"revoked": 1})
|
||||
|
||||
c.Redirect("/auth/devices?success="+url.QueryEscape("已将该设备下线"), 302)
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
authsvc "server/services/auth"
|
||||
"server/services/auth/idp"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
)
|
||||
|
||||
// authBase 认证中心对外基址(用于拼接第三方回调地址)
|
||||
func authBase(c *AuthThirdController) string {
|
||||
if iss := jwtutil.Issuer(); iss != "" {
|
||||
return iss
|
||||
}
|
||||
scheme := "https"
|
||||
if c.Ctx.Request.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + c.Ctx.Request.Host + "/auth"
|
||||
}
|
||||
|
||||
// claimsFromCtxThird 从 Authorization 头解析登录态
|
||||
func claimsFromCtxThird(ctx *context.Context) *jwtutil.Claims {
|
||||
header := ctx.Request.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.SplitN(header, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
// 第三方登录未绑定时的错误
|
||||
var ErrThirdNotBound = errors.New("该第三方账号尚未绑定,请先用账号密码登录后绑定")
|
||||
|
||||
// AuthThirdController 第三方登录(微信/钉钉/飞书/QQ/GitHub/Google)
|
||||
type AuthThirdController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *AuthThirdController) serveJSON(data map[string]interface{}) {
|
||||
c.Data["json"] = data
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// thirdState 第三方授权流程中通过 state 透传的上下文(OIDC 原始参数)
|
||||
type thirdState struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
Method string `json:"code_challenge_method"`
|
||||
Scope string `json:"scope"`
|
||||
Nonce string `json:"nonce"`
|
||||
OIDCState string `json:"oidc_state"`
|
||||
Tid uint64 `json:"tid"`
|
||||
// Action:login=第三方登录(默认);bind=已登录用户绑定第三方账号
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// List 已开通的第三方登录方式(供登录页展示按钮)
|
||||
// GET /auth/third/list?tid=
|
||||
func (c *AuthThirdController) List() {
|
||||
tid, _ := c.GetInt64("tid", 0)
|
||||
list := make([]map[string]interface{}, 0)
|
||||
for _, p := range idp.Supported() {
|
||||
_, configured := idp.LoadConfig(p, uint64(tid))
|
||||
list = append(list, map[string]interface{}{
|
||||
"provider": p,
|
||||
"name": providerName(p),
|
||||
"enabled": configured,
|
||||
})
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
|
||||
}
|
||||
|
||||
// Authorize 跳转到第三方授权页
|
||||
// GET /auth/third/:provider/authorize?client_id=&redirect_uri=&code_challenge=&state=&tid=
|
||||
func (c *AuthThirdController) Authorize() {
|
||||
provider := strings.TrimSpace(c.Ctx.Input.Param(":provider"))
|
||||
if !idp.IsSupported(provider) {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
_, _ = c.Ctx.ResponseWriter.Write([]byte("不支持的登录方式: " + provider))
|
||||
return
|
||||
}
|
||||
tid, _ := c.GetInt64("tid", 0)
|
||||
|
||||
cfg, configured := idp.LoadConfig(provider, uint64(tid))
|
||||
if !configured || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
_, _ = c.Ctx.ResponseWriter.Write([]byte("该登录方式尚未开通,请联系管理员配置"))
|
||||
return
|
||||
}
|
||||
|
||||
st := thirdState{
|
||||
ClientID: c.GetString("client_id"),
|
||||
RedirectURI: c.GetString("redirect_uri"),
|
||||
CodeChallenge: c.GetString("code_challenge"),
|
||||
Method: c.GetString("code_challenge_method"),
|
||||
Scope: c.GetString("scope"),
|
||||
Nonce: c.GetString("nonce"),
|
||||
OIDCState: c.GetString("state"),
|
||||
Tid: uint64(tid),
|
||||
Action: strings.TrimSpace(c.GetString("action")),
|
||||
}
|
||||
|
||||
// 登录页场景:参数封装在 redirect(base64 编码的 authorize URL)里,
|
||||
// 这里解出来,使第三方登录完成后能回到原来的 OIDC 授权流程。
|
||||
if enc := c.GetString("redirect"); enc != "" && st.ClientID == "" {
|
||||
if raw, err := base64.RawURLEncoding.DecodeString(enc); err == nil {
|
||||
if u, err := url.Parse(string(raw)); err == nil {
|
||||
q := u.Query()
|
||||
st.ClientID = q.Get("client_id")
|
||||
st.RedirectURI = q.Get("redirect_uri")
|
||||
st.CodeChallenge = q.Get("code_challenge")
|
||||
st.Method = q.Get("code_challenge_method")
|
||||
st.Scope = q.Get("scope")
|
||||
st.Nonce = q.Get("nonce")
|
||||
st.OIDCState = q.Get("state")
|
||||
}
|
||||
}
|
||||
}
|
||||
// 第三方登录仅面向租户域应用:平台端(platform.yunzer.cn)不走认证中心,
|
||||
// 这里再校验一次 realm,避免平台域应用被误接入。
|
||||
if st.ClientID != "" {
|
||||
if client, err := findClient(st.ClientID); err == nil && client.Realm != models.AuthRealmTenant {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
_, _ = c.Ctx.ResponseWriter.Write([]byte("该应用不支持第三方登录"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(500)
|
||||
return
|
||||
}
|
||||
state := base64.RawURLEncoding.EncodeToString(raw)
|
||||
|
||||
// 注意:authBase 已包含 /auth 前缀(issuer = https://api.yunzer.cn/auth),
|
||||
// 这里不能再拼 /auth,否则回调地址会变成 /auth/auth/third/...
|
||||
callbackURI := authBase(c) + "/third/" + provider + "/callback"
|
||||
c.Redirect(idp.BuildAuthURL(cfg, callbackURI, state), 302)
|
||||
}
|
||||
|
||||
// Callback 第三方回调:换取身份 → 建会话 → 回到 OIDC authorize 继续发码
|
||||
// GET /auth/third/:provider/callback?code=&state=
|
||||
func (c *AuthThirdController) Callback() {
|
||||
provider := strings.TrimSpace(c.Ctx.Input.Param(":provider"))
|
||||
code := c.GetString("code")
|
||||
stateParam := c.GetString("state")
|
||||
|
||||
fail := func(msg string) {
|
||||
c.Redirect("/auth/login?error="+url.QueryEscape(msg), 302)
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
fail("第三方登录失败:缺少授权码")
|
||||
return
|
||||
}
|
||||
|
||||
var st thirdState
|
||||
if raw, err := base64.RawURLEncoding.DecodeString(stateParam); err == nil {
|
||||
_ = json.Unmarshal(raw, &st)
|
||||
}
|
||||
|
||||
cfg, configured := idp.LoadConfig(provider, st.Tid)
|
||||
if !configured {
|
||||
fail("该登录方式尚未开通")
|
||||
return
|
||||
}
|
||||
info, err := idp.Exchange(cfg, code)
|
||||
if err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 绑定场景:把第三方账号挂到当前登录身份上
|
||||
if st.Action == "bind" {
|
||||
finishBind(c, provider, info)
|
||||
return
|
||||
}
|
||||
|
||||
// 查找绑定关系:open_id 优先,其次 union_id
|
||||
bind, err := findThirdBinding(provider, info.OpenID, info.UnionID)
|
||||
if err != nil {
|
||||
fail(ErrThirdNotBound.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 校验身份状态
|
||||
var identity models.AuthIdentity
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", bind.IdentityID).One(&identity); err != nil {
|
||||
fail("账号不存在")
|
||||
return
|
||||
}
|
||||
if identity.Status == models.AuthIdentityStatusDisabled {
|
||||
fail("账号已禁用")
|
||||
return
|
||||
}
|
||||
|
||||
// 建会话(tid=0 表示待选企业,authorize 会引导用户选择)
|
||||
sess, err := authsvc.CreateSession(authsvc.SessionInfo{
|
||||
IdentityID: identity.ID,
|
||||
Tid: authsvc.PendingTenantID,
|
||||
ClientID: st.ClientID,
|
||||
IP: c.Ctx.Input.IP(),
|
||||
UserAgent: c.Ctx.Request.UserAgent(),
|
||||
LoginType: authsvc.LoginTypeThird,
|
||||
Amr: provider,
|
||||
})
|
||||
if err != nil {
|
||||
fail("创建会话失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 更新绑定信息(昵称/头像可能变化)
|
||||
update := map[string]interface{}{}
|
||||
if info.Nickname != "" {
|
||||
update["nickname"] = info.Nickname
|
||||
}
|
||||
if info.Avatar != "" {
|
||||
update["avatar"] = info.Avatar
|
||||
}
|
||||
if info.Raw != "" {
|
||||
update["raw"] = info.Raw
|
||||
}
|
||||
if len(update) > 0 {
|
||||
_, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("id", bind.ID).Update(update)
|
||||
}
|
||||
|
||||
setSessionCookieForCtx(c.Ctx, sess.Sid)
|
||||
|
||||
// 回到 OIDC 授权流程继续签发授权码
|
||||
if st.ClientID != "" && st.RedirectURI != "" {
|
||||
v := url.Values{}
|
||||
v.Set("client_id", st.ClientID)
|
||||
v.Set("redirect_uri", st.RedirectURI)
|
||||
v.Set("response_type", ResponseTypeCode)
|
||||
v.Set("code_challenge", st.CodeChallenge)
|
||||
v.Set("code_challenge_method", defaultStr(st.Method, "S256"))
|
||||
if st.Scope != "" {
|
||||
v.Set("scope", st.Scope)
|
||||
}
|
||||
if st.Nonce != "" {
|
||||
v.Set("nonce", st.Nonce)
|
||||
}
|
||||
if st.OIDCState != "" {
|
||||
v.Set("state", st.OIDCState)
|
||||
}
|
||||
c.Redirect("/auth/authorize?"+v.Encode(), 302)
|
||||
return
|
||||
}
|
||||
c.Redirect("/auth/login", 302)
|
||||
}
|
||||
|
||||
// Bind 已登录用户绑定第三方账号
|
||||
// POST /auth/third/bind { provider, code, tid }
|
||||
func (c *AuthThirdController) Bind() {
|
||||
claims := claimsFromCtxThird(c.Ctx)
|
||||
if claims == nil || claims.UserID <= 0 {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
Code string `json:"code"`
|
||||
Tid uint64 `json:"tid"`
|
||||
}
|
||||
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
|
||||
req.Provider = strings.TrimSpace(req.Provider)
|
||||
if !idp.IsSupported(req.Provider) || req.Code == "" {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, configured := idp.LoadConfig(req.Provider, req.Tid)
|
||||
if !configured {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该登录方式尚未开通"})
|
||||
return
|
||||
}
|
||||
info, err := idp.Exchange(cfg, req.Code)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 该第三方账号若已绑定到别的身份,拒绝重复绑定
|
||||
if exist := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("provider", req.Provider).
|
||||
Filter("open_id", info.OpenID).Exist(); exist {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该第三方账号已被其他账号绑定"})
|
||||
return
|
||||
}
|
||||
|
||||
item := &models.AuthIdentityThird{
|
||||
IdentityID: uint64(claims.UserID),
|
||||
Provider: req.Provider,
|
||||
OpenID: info.OpenID,
|
||||
Nickname: strPtr(info.Nickname),
|
||||
Avatar: strPtr(info.Avatar),
|
||||
Raw: strPtr(info.Raw),
|
||||
}
|
||||
if info.UnionID != "" {
|
||||
item.UnionID = &info.UnionID
|
||||
}
|
||||
if _, err := models.Orm.Insert(item); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "绑定失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "绑定成功"})
|
||||
}
|
||||
|
||||
// BoundList 当前登录身份已绑定的第三方账号
|
||||
// GET /auth/third/bound
|
||||
func (c *AuthThirdController) BoundList() {
|
||||
claims := claimsFromCtxThird(c.Ctx)
|
||||
if claims == nil || claims.UserID <= 0 {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||
return
|
||||
}
|
||||
var rows []models.AuthIdentityThird
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("identity_id", uint64(claims.UserID)).All(&rows); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"})
|
||||
return
|
||||
}
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": r.ID,
|
||||
"provider": r.Provider,
|
||||
"name": providerName(r.Provider),
|
||||
"nickname": deref(r.Nickname),
|
||||
"bind_time": r.BindTime,
|
||||
})
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
|
||||
}
|
||||
|
||||
// Unbind 解绑第三方账号
|
||||
// POST /auth/third/unbind { provider }
|
||||
func (c *AuthThirdController) Unbind() {
|
||||
claims := claimsFromCtxThird(c.Ctx)
|
||||
if claims == nil || claims.UserID <= 0 {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
|
||||
if strings.TrimSpace(req.Provider) == "" {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||
return
|
||||
}
|
||||
// 至少保留一种登录方式:若未设置密码则不允许解绑最后一个第三方
|
||||
var identity models.AuthIdentity
|
||||
// 未设置密码的用户,解绑最后一个第三方账号后将无法登录
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", uint64(claims.UserID)).One(&identity); err == nil {
|
||||
hasPassword := identity.PasswordHash != nil && *identity.PasswordHash != ""
|
||||
if !hasPassword {
|
||||
cnt, _ := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("identity_id", identity.ID).Count()
|
||||
if cnt <= 1 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "未设置密码,不能解绑最后一个第三方账号"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("identity_id", uint64(claims.UserID)).
|
||||
Filter("provider", strings.TrimSpace(req.Provider)).
|
||||
Delete(); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "解绑失败"})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已解绑"})
|
||||
}
|
||||
|
||||
// BindPage 第三方账号绑定管理页(浏览器访问,依赖认证中心会话 Cookie)
|
||||
// GET /auth/bind
|
||||
func (c *AuthThirdController) BindPage() {
|
||||
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||
if sid == "" {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
session, err := authsvc.GetSession(sid)
|
||||
if err != nil {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
|
||||
// 已绑定列表
|
||||
var bound []models.AuthIdentityThird
|
||||
_, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("identity_id", session.IdentityID).All(&bound)
|
||||
boundMap := map[string]models.AuthIdentityThird{}
|
||||
for _, b := range bound {
|
||||
boundMap[b.Provider] = b
|
||||
}
|
||||
|
||||
// 全部平台(只展示已开通的)
|
||||
items := make([]map[string]interface{}, 0)
|
||||
for _, p := range idp.Supported() {
|
||||
_, configured := idp.LoadConfig(p, session.Tid)
|
||||
if !configured {
|
||||
continue
|
||||
}
|
||||
item := map[string]interface{}{
|
||||
"provider": p,
|
||||
"name": providerName(p),
|
||||
"bound": false,
|
||||
"nickname": "",
|
||||
}
|
||||
if b, ok := boundMap[p]; ok {
|
||||
item["bound"] = true
|
||||
item["nickname"] = deref(b.Nickname)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
c.Data["Providers"] = items
|
||||
c.Data["Success"] = c.GetString("success")
|
||||
c.Data["Error"] = c.GetString("error")
|
||||
c.TplName = "auth/bind.tpl"
|
||||
}
|
||||
|
||||
// UnbindPage 解绑(页面入口,用会话 Cookie 鉴权,解绑后回到绑定页)
|
||||
// GET /auth/third/:provider/unbind
|
||||
func (c *AuthThirdController) UnbindPage() {
|
||||
provider := strings.TrimSpace(c.Ctx.Input.Param(":provider"))
|
||||
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||
if sid == "" {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
session, err := authsvc.GetSession(sid)
|
||||
if err != nil {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
|
||||
var identity models.AuthIdentity
|
||||
hasPassword := true
|
||||
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||
Filter("id", session.IdentityID).One(&identity); err == nil {
|
||||
hasPassword = identity.PasswordHash != nil && *identity.PasswordHash != ""
|
||||
}
|
||||
if !hasPassword {
|
||||
cnt, _ := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("identity_id", session.IdentityID).Count()
|
||||
if cnt <= 1 {
|
||||
c.Redirect("/auth/bind?error="+url.QueryEscape("未设置密码,不能解绑最后一个第三方账号"), 302)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("identity_id", session.IdentityID).
|
||||
Filter("provider", provider).Delete(); err != nil {
|
||||
c.Redirect("/auth/bind?error="+url.QueryEscape("解绑失败"), 302)
|
||||
return
|
||||
}
|
||||
c.Redirect("/auth/bind?success="+url.QueryEscape("已解绑"), 302)
|
||||
}
|
||||
|
||||
// finishBind 绑定场景的回调处理:把第三方账号挂到当前登录身份
|
||||
func finishBind(c *AuthThirdController, provider string, info *idp.UserInfo) {
|
||||
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||
if sid == "" {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
session, err := authsvc.GetSession(sid)
|
||||
if err != nil {
|
||||
c.Redirect("/auth/login", 302)
|
||||
return
|
||||
}
|
||||
|
||||
// 该第三方账号已绑定到别的身份 → 拒绝
|
||||
if exist := models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("provider", provider).
|
||||
Filter("open_id", info.OpenID).Exist(); exist {
|
||||
c.Redirect("/auth/bind?error="+url.QueryEscape("该"+providerName(provider)+"账号已被其他账号绑定"), 302)
|
||||
return
|
||||
}
|
||||
// 同一身份重复绑定同一平台 → 先清掉旧的
|
||||
_, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).
|
||||
Filter("identity_id", session.IdentityID).
|
||||
Filter("provider", provider).Delete()
|
||||
|
||||
item := &models.AuthIdentityThird{
|
||||
IdentityID: session.IdentityID,
|
||||
Provider: provider,
|
||||
OpenID: info.OpenID,
|
||||
Nickname: strPtr(info.Nickname),
|
||||
Avatar: strPtr(info.Avatar),
|
||||
Raw: strPtr(info.Raw),
|
||||
}
|
||||
if info.UnionID != "" {
|
||||
item.UnionID = &info.UnionID
|
||||
}
|
||||
if _, err := models.Orm.Insert(item); err != nil {
|
||||
c.Redirect("/auth/bind?error="+url.QueryEscape("绑定失败"), 302)
|
||||
return
|
||||
}
|
||||
c.Redirect("/auth/bind?success="+url.QueryEscape("绑定成功"), 302)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
|
||||
// findThirdBinding 按 open_id 查找,其次按 union_id 查找
|
||||
func findThirdBinding(provider, openID, unionID string) (*models.AuthIdentityThird, error) {
|
||||
var bind models.AuthIdentityThird
|
||||
qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", provider)
|
||||
|
||||
if openID != "" {
|
||||
if err := qs.Filter("open_id", openID).One(&bind); err == nil {
|
||||
return &bind, nil
|
||||
}
|
||||
}
|
||||
if unionID != "" {
|
||||
if err := qs.Filter("union_id", unionID).One(&bind); err == nil {
|
||||
return &bind, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("not bound")
|
||||
}
|
||||
|
||||
func providerName(p string) string {
|
||||
switch p {
|
||||
case models.IdPWechat:
|
||||
return "微信"
|
||||
case models.IdPDingTalk:
|
||||
return "钉钉"
|
||||
case models.IdPFeishu:
|
||||
return "飞书"
|
||||
case models.IdPQQ:
|
||||
return "QQ"
|
||||
case models.IdPGitHub:
|
||||
return "GitHub"
|
||||
case models.IdPGoogle:
|
||||
return "Google"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func defaultStr(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// PlatformAuthClientController 统一认证中心 —— 接入应用(OIDC Client)管理。
|
||||
//
|
||||
// 以后每开发一个新软件,只需在这里注册一条即可接入统一认证,
|
||||
// 无需改代码、无需手写 SQL。
|
||||
type PlatformAuthClientController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *PlatformAuthClientController) serveJSON(data map[string]interface{}) {
|
||||
c.Data["json"] = data
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Prepare 统一鉴权:接入应用属于平台级敏感配置,仅平台管理员可操作。
|
||||
// 不依赖全局中间件(其处于 warn 观察模式时不拦截),这里主动校验。
|
||||
func (c *PlatformAuthClientController) Prepare() {
|
||||
authHeader := c.Ctx.Request.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
// 平台端应用注册只允许平台管理员操作;租户管理员无权访问
|
||||
if claims.UserType != "platform" {
|
||||
c.Ctx.Output.SetStatus(403)
|
||||
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type authClientPayload struct {
|
||||
ClientID string `json:"client_id"`
|
||||
AppCode string `json:"app_code"`
|
||||
Name string `json:"name"`
|
||||
AppType int8 `json:"app_type"`
|
||||
RedirectURIs *string `json:"redirect_uris"`
|
||||
PostLogoutURIs *string `json:"post_logout_uris"`
|
||||
BackchannelLogoutURI *string `json:"backchannel_logout_uri"`
|
||||
GrantTypes string `json:"grant_types"`
|
||||
Scope *string `json:"scope"`
|
||||
AccessTTL int `json:"access_ttl"`
|
||||
RefreshTTL int `json:"refresh_ttl"`
|
||||
Realm string `json:"realm"`
|
||||
Status *int8 `json:"status"`
|
||||
}
|
||||
|
||||
func (c *PlatformAuthClientController) parsePayload() (authClientPayload, bool) {
|
||||
var p authClientPayload
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||
return p, false
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// List 接入应用列表(不返回 client_secret)
|
||||
// GET /platform/authClient/list
|
||||
func (c *PlatformAuthClientController) List() {
|
||||
var rows []models.AuthClient
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
|
||||
OrderBy("-id").All(&rows); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
// 脱敏:只告知是否配置了密钥,不下发明文或哈希
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": r.ID,
|
||||
"client_id": r.ClientID,
|
||||
"app_code": r.AppCode,
|
||||
"name": r.Name,
|
||||
"app_type": r.AppType,
|
||||
"redirect_uris": derefStrAuth(r.RedirectURIs),
|
||||
"post_logout_uris": derefStrAuth(r.PostLogoutURIs),
|
||||
"backchannel_logout_uri": derefStrAuth(r.BackchannelLogoutURI),
|
||||
"grant_types": r.GrantTypes,
|
||||
"scope": derefStrAuth(r.Scope),
|
||||
"access_ttl": r.AccessTTL,
|
||||
"refresh_ttl": r.RefreshTTL,
|
||||
"realm": r.Realm,
|
||||
"status": r.Status,
|
||||
"has_secret": r.ClientSecret != nil,
|
||||
"create_time": r.CreateTime,
|
||||
})
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200, "msg": "success",
|
||||
"data": map[string]interface{}{"list": list, "total": len(list)},
|
||||
})
|
||||
}
|
||||
|
||||
// Detail 应用详情
|
||||
// GET /platform/authClient/detail/:id
|
||||
func (c *PlatformAuthClientController) Detail() {
|
||||
id, err := c.parseID()
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
|
||||
return
|
||||
}
|
||||
var row models.AuthClient
|
||||
if err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).One(&row); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
|
||||
return
|
||||
}
|
||||
row.ClientSecret = nil
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": row})
|
||||
}
|
||||
|
||||
// Create 新增应用
|
||||
// POST /platform/authClient/create
|
||||
func (c *PlatformAuthClientController) Create() {
|
||||
p, ok := c.parsePayload()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.ClientID = strings.TrimSpace(p.ClientID)
|
||||
p.AppCode = strings.TrimSpace(p.AppCode)
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.ClientID == "" || p.AppCode == "" || p.Name == "" {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "client_id、应用编码、名称均不能为空"})
|
||||
return
|
||||
}
|
||||
if err := validateJSONStringArray(p.RedirectURIs); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "redirect_uris 必须是 JSON 数组: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateJSONStringArray(p.PostLogoutURIs); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "post_logout_uris 必须是 JSON 数组: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if exist := existsAuthClient("client_id", p.ClientID); exist {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "client_id 已存在"})
|
||||
return
|
||||
}
|
||||
if exist := existsAuthClient("app_code", p.AppCode); exist {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "应用编码已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
item := &models.AuthClient{
|
||||
ClientID: p.ClientID,
|
||||
AppCode: p.AppCode,
|
||||
Name: p.Name,
|
||||
AppType: p.AppType,
|
||||
RedirectURIs: p.RedirectURIs,
|
||||
PostLogoutURIs: p.PostLogoutURIs,
|
||||
BackchannelLogoutURI: p.BackchannelLogoutURI,
|
||||
GrantTypes: defaultStrAuth(p.GrantTypes, "authorization_code,refresh_token"),
|
||||
Scope: p.Scope,
|
||||
AccessTTL: defaultIntAuth(p.AccessTTL, 1800),
|
||||
RefreshTTL: defaultIntAuth(p.RefreshTTL, 2592000),
|
||||
Realm: defaultStrAuth(p.Realm, models.AuthRealmTenant),
|
||||
Status: 1,
|
||||
}
|
||||
if p.Status != nil {
|
||||
item.Status = *p.Status
|
||||
}
|
||||
|
||||
// 机密客户端(有后端的 Web 应用)才下发密钥;SPA/APP 走 PKCE,不持有密钥
|
||||
secretPlain := ""
|
||||
if p.AppType == models.AuthAppTypeWeb {
|
||||
plain, hashed, err := generateClientSecret()
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "生成密钥失败"})
|
||||
return
|
||||
}
|
||||
secretPlain = plain
|
||||
item.ClientSecret = &hashed
|
||||
}
|
||||
|
||||
id, err := models.Orm.Insert(item)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "创建成功" + map[bool]string{true: "(请妥善保存密钥,仅此一次显示)", false: ""}[secretPlain != ""],
|
||||
"data": map[string]interface{}{"id": id, "client_secret": secretPlain},
|
||||
})
|
||||
}
|
||||
|
||||
// Edit 编辑应用
|
||||
// POST /platform/authClient/edit/:id
|
||||
func (c *PlatformAuthClientController) Edit() {
|
||||
id, err := c.parseID()
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
|
||||
return
|
||||
}
|
||||
p, ok := c.parsePayload()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := validateJSONStringArray(p.RedirectURIs); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "redirect_uris 必须是 JSON 数组: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateJSONStringArray(p.PostLogoutURIs); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "post_logout_uris 必须是 JSON 数组: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
update := map[string]interface{}{}
|
||||
if v := strings.TrimSpace(p.Name); v != "" {
|
||||
update["name"] = v
|
||||
}
|
||||
if p.AppType > 0 {
|
||||
update["app_type"] = p.AppType
|
||||
}
|
||||
if p.RedirectURIs != nil {
|
||||
update["redirect_uris"] = *p.RedirectURIs
|
||||
}
|
||||
if p.PostLogoutURIs != nil {
|
||||
update["post_logout_uris"] = *p.PostLogoutURIs
|
||||
}
|
||||
if p.BackchannelLogoutURI != nil {
|
||||
update["backchannel_logout_uri"] = *p.BackchannelLogoutURI
|
||||
}
|
||||
if v := strings.TrimSpace(p.GrantTypes); v != "" {
|
||||
update["grant_types"] = v
|
||||
}
|
||||
if p.Scope != nil {
|
||||
update["scope"] = *p.Scope
|
||||
}
|
||||
if p.AccessTTL > 0 {
|
||||
update["access_ttl"] = p.AccessTTL
|
||||
}
|
||||
if p.RefreshTTL > 0 {
|
||||
update["refresh_ttl"] = p.RefreshTTL
|
||||
}
|
||||
if v := strings.TrimSpace(p.Realm); v != "" {
|
||||
update["realm"] = v
|
||||
}
|
||||
if p.Status != nil {
|
||||
update["status"] = *p.Status
|
||||
}
|
||||
if len(update) == 0 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无更新字段"})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).Update(update); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
|
||||
}
|
||||
|
||||
// ResetSecret 重置密钥(仅机密客户端),返回新明文,仅此一次显示
|
||||
// POST /platform/authClient/resetSecret/:id
|
||||
func (c *PlatformAuthClientController) ResetSecret() {
|
||||
id, err := c.parseID()
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
|
||||
return
|
||||
}
|
||||
var row models.AuthClient
|
||||
if err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).One(&row); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
|
||||
return
|
||||
}
|
||||
if row.AppType != models.AuthAppTypeWeb {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "仅「Web 后端」类型的应用需要密钥,SPA/APP 使用 PKCE"})
|
||||
return
|
||||
}
|
||||
plain, hashed, err := generateClientSecret()
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "生成密钥失败"})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
|
||||
Filter("id", id).
|
||||
Update(map[string]interface{}{"client_secret": hashed}); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "重置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200, "msg": "重置成功,请妥善保存新密钥",
|
||||
"data": map[string]interface{}{"client_secret": plain},
|
||||
})
|
||||
}
|
||||
|
||||
// Delete 停用应用(逻辑删除:status=0,避免误删导致线上应用无法登录)
|
||||
// DELETE /platform/authClient/delete/:id
|
||||
func (c *PlatformAuthClientController) Delete() {
|
||||
id, err := c.parseID()
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
|
||||
Filter("id", id).
|
||||
Update(map[string]interface{}{"status": 0}); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "停用失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已停用"})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
|
||||
func (c *PlatformAuthClientController) parseID() (uint64, error) {
|
||||
var id uint64
|
||||
_, err := fmt.Sscanf(c.Ctx.Input.Param(":id"), "%d", &id)
|
||||
if err != nil || id == 0 {
|
||||
return 0, fmt.Errorf("invalid id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func existsAuthClient(field, value string) bool {
|
||||
return models.Orm.QueryTable(new(models.AuthClient)).Filter(field, value).Exist()
|
||||
}
|
||||
|
||||
// generateClientSecret 生成密钥:明文只在创建/重置时返回一次,库里存 sha256 哈希
|
||||
func generateClientSecret() (plain, hashed string, err error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err = rand.Read(buf); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
plain = "yzs_" + hex.EncodeToString(buf)
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return plain, hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// validateJSONStringArray 校验字段为合法的字符串数组(为空时跳过)
|
||||
func validateJSONStringArray(p *string) error {
|
||||
if p == nil || strings.TrimSpace(*p) == "" {
|
||||
return nil
|
||||
}
|
||||
var arr []string
|
||||
return json.Unmarshal([]byte(*p), &arr)
|
||||
}
|
||||
|
||||
func derefStrAuth(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func defaultStrAuth(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
func defaultIntAuth(v, def int) int {
|
||||
if v <= 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// PlatformAuthConfigController 统一认证 —— 租户登录策略配置(平台端管理)
|
||||
//
|
||||
// 可配置:登录验证码方式、密码强度、会话时长、同账号最大在线设备数(1号1机)
|
||||
// 以及超限策略(踢掉旧会话 / 拒绝新登录)等。
|
||||
type PlatformAuthConfigController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *PlatformAuthConfigController) serveJSON(data map[string]interface{}) {
|
||||
c.Data["json"] = data
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Prepare 统一鉴权:仅平台管理员可配置
|
||||
func (c *PlatformAuthConfigController) Prepare() {
|
||||
authHeader := c.Ctx.Request.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
if claims.UserType != "platform" {
|
||||
c.Ctx.Output.SetStatus(403)
|
||||
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Detail 查询租户登录策略;未配置时返回系统默认值
|
||||
// GET /platform/authConfig/detail?tid=
|
||||
func (c *PlatformAuthConfigController) Detail() {
|
||||
tid, _ := c.GetInt64("tid", 0)
|
||||
if tid == 0 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "tid 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
var cfg models.AuthTenantAuthConfig
|
||||
err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
|
||||
Filter("tid", tid).One(&cfg)
|
||||
|
||||
if err != nil {
|
||||
// 未配置:返回默认值
|
||||
policy := authsvc.GetTenantSessionPolicy(uint64(tid))
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tid": tid,
|
||||
"verify_type": "captcha",
|
||||
"open_verify": 1,
|
||||
"pwd_min_len": 8,
|
||||
"pwd_complexity": 0,
|
||||
"session_ttl": policy.SessionTTL,
|
||||
"max_session": policy.MaxSession,
|
||||
"kick_strategy": policy.KickStrategy,
|
||||
"mfa_required": 0,
|
||||
"ip_whitelist": "",
|
||||
"allow_third": "",
|
||||
"configured": false,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ipList := ""
|
||||
if cfg.IPWhitelist != nil {
|
||||
ipList = *cfg.IPWhitelist
|
||||
}
|
||||
allowThird := ""
|
||||
if cfg.AllowThird != nil {
|
||||
allowThird = *cfg.AllowThird
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tid": cfg.Tid,
|
||||
"verify_type": cfg.VerifyType,
|
||||
"open_verify": cfg.OpenVerify,
|
||||
"pwd_min_len": cfg.PwdMinLen,
|
||||
"pwd_complexity": cfg.PwdComplexity,
|
||||
"session_ttl": cfg.SessionTTL,
|
||||
"max_session": cfg.MaxSession,
|
||||
"kick_strategy": cfg.KickStrategy,
|
||||
"mfa_required": cfg.MfaRequired,
|
||||
"ip_whitelist": ipList,
|
||||
"allow_third": allowThird,
|
||||
"configured": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Save 保存租户登录策略(不存在则创建)
|
||||
// POST /platform/authConfig/save
|
||||
func (c *PlatformAuthConfigController) Save() {
|
||||
var p struct {
|
||||
Tid uint64 `json:"tid"`
|
||||
VerifyType string `json:"verify_type"`
|
||||
OpenVerify *int8 `json:"open_verify"`
|
||||
PwdMinLen int `json:"pwd_min_len"`
|
||||
PwdComplexity int8 `json:"pwd_complexity"`
|
||||
SessionTTL int `json:"session_ttl"`
|
||||
MaxSession int `json:"max_session"`
|
||||
KickStrategy int8 `json:"kick_strategy"`
|
||||
MfaRequired int8 `json:"mfa_required"`
|
||||
IPWhitelist string `json:"ip_whitelist"`
|
||||
AllowThird string `json:"allow_third"`
|
||||
}
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if p.Tid == 0 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "tid 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
// 校验 IP 白名单为合法 JSON 数组
|
||||
ip := strings.TrimSpace(p.IPWhitelist)
|
||||
if ip != "" {
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(ip), &arr); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "IP白名单必须是 JSON 数组,如 [\"1.2.3.4\"]"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg := models.AuthTenantAuthConfig{
|
||||
Tid: p.Tid,
|
||||
VerifyType: defaultStrAuth2(p.VerifyType, "captcha"),
|
||||
OpenVerify: 1,
|
||||
PwdMinLen: p.PwdMinLen,
|
||||
PwdComplexity: p.PwdComplexity,
|
||||
SessionTTL: p.SessionTTL,
|
||||
MaxSession: p.MaxSession,
|
||||
KickStrategy: p.KickStrategy,
|
||||
MfaRequired: p.MfaRequired,
|
||||
}
|
||||
if p.OpenVerify != nil {
|
||||
cfg.OpenVerify = *p.OpenVerify
|
||||
}
|
||||
if cfg.PwdMinLen <= 0 {
|
||||
cfg.PwdMinLen = 8
|
||||
}
|
||||
if cfg.SessionTTL <= 0 {
|
||||
cfg.SessionTTL = authsvc.DefaultSessionTTL
|
||||
}
|
||||
if cfg.MaxSession <= 0 {
|
||||
cfg.MaxSession = authsvc.DefaultMaxSession
|
||||
}
|
||||
if cfg.KickStrategy != models.KickStrategyReject {
|
||||
cfg.KickStrategy = models.KickStrategyKickOld
|
||||
}
|
||||
if ip != "" {
|
||||
cfg.IPWhitelist = &ip
|
||||
}
|
||||
if v := strings.TrimSpace(p.AllowThird); v != "" {
|
||||
cfg.AllowThird = &v
|
||||
}
|
||||
|
||||
exist := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).Filter("tid", p.Tid).Exist()
|
||||
if exist {
|
||||
fields := []string{"verify_type", "open_verify", "pwd_min_len", "pwd_complexity",
|
||||
"session_ttl", "max_session", "kick_strategy", "mfa_required"}
|
||||
if cfg.IPWhitelist != nil {
|
||||
fields = append(fields, "ip_whitelist")
|
||||
}
|
||||
if cfg.AllowThird != nil {
|
||||
fields = append(fields, "allow_third")
|
||||
}
|
||||
if _, err := models.Orm.Update(&cfg, fields...); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if _, err := models.Orm.Insert(&cfg); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "保存失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
|
||||
}
|
||||
|
||||
// Reset 恢复默认值(删除自定义配置)
|
||||
// DELETE /platform/authConfig/reset/:tid
|
||||
func (c *PlatformAuthConfigController) Reset() {
|
||||
tid, err := strconv.ParseUint(c.Ctx.Input.Param(":tid"), 10, 64)
|
||||
if err != nil || tid == 0 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效 tid"})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
|
||||
Filter("tid", tid).Delete(); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "重置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已恢复默认"})
|
||||
}
|
||||
|
||||
func defaultStrAuth2(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// PlatformAuthIdpController 统一认证 —— 第三方登录配置(平台端管理)
|
||||
//
|
||||
// 配置说明:
|
||||
// - tid = 0 :平台全局配置,所有租户共用
|
||||
// - tid > 0 :租户自带身份源,仅该租户可见(第10条需求:企业自带钉钉/飞书等)
|
||||
//
|
||||
// 配置对象只有「租户用户登录」,平台端自身不使用第三方登录。
|
||||
type PlatformAuthIdpController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *PlatformAuthIdpController) serveJSON(data map[string]interface{}) {
|
||||
c.Data["json"] = data
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Prepare 统一鉴权:仅平台管理员可维护第三方登录配置
|
||||
func (c *PlatformAuthIdpController) Prepare() {
|
||||
authHeader := c.Ctx.Request.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(401)
|
||||
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
if claims.UserType != "platform" {
|
||||
c.Ctx.Output.SetStatus(403)
|
||||
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
|
||||
c.StopRun()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type authIdpPayload struct {
|
||||
Tid uint64 `json:"tid"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
ProxyURL string `json:"proxy_url"`
|
||||
Scopes string `json:"scopes"`
|
||||
AuthURL string `json:"auth_url"`
|
||||
TokenURL string `json:"token_url"`
|
||||
UserinfoURL string `json:"userinfo_url"`
|
||||
Status *int8 `json:"status"`
|
||||
}
|
||||
|
||||
func (c *PlatformAuthIdpController) parsePayload() (authIdpPayload, bool) {
|
||||
var p authIdpPayload
|
||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||
return p, false
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// List 配置列表
|
||||
// GET /platform/authIdp/list?tid=0
|
||||
func (c *PlatformAuthIdpController) List() {
|
||||
tid, _ := c.GetInt64("tid", 0)
|
||||
var rows []models.AuthTenantIdp
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthTenantIdp)).
|
||||
Filter("tid", tid).OrderBy("id").All(&rows); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 脱敏:不下发 AppSecret
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": r.ID,
|
||||
"tid": r.Tid,
|
||||
"provider": r.Provider,
|
||||
"name": derefAuthIdp(r.Name),
|
||||
"app_id": derefAuthIdp(r.AppID),
|
||||
"proxy_url": derefAuthIdp(r.ProxyURL),
|
||||
"scopes": derefAuthIdp(r.Scopes),
|
||||
"auth_url": derefAuthIdp(r.AuthURL),
|
||||
"token_url": derefAuthIdp(r.TokenURL),
|
||||
"userinfo_url": derefAuthIdp(r.UserinfoURL),
|
||||
"status": r.Status,
|
||||
"has_secret": r.AppSecret != nil && *r.AppSecret != "",
|
||||
})
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200, "msg": "success",
|
||||
"data": map[string]interface{}{"list": list, "total": len(list)},
|
||||
})
|
||||
}
|
||||
|
||||
// Create 新增配置
|
||||
// POST /platform/authIdp/create
|
||||
func (c *PlatformAuthIdpController) Create() {
|
||||
p, ok := c.parsePayload()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.Provider = strings.TrimSpace(p.Provider)
|
||||
if p.Provider == "" {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "provider 不能为空"})
|
||||
return
|
||||
}
|
||||
if exist := models.Orm.QueryTable(new(models.AuthTenantIdp)).
|
||||
Filter("tid", p.Tid).Filter("provider", p.Provider).Exist(); exist {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该登录方式已配置,请直接编辑"})
|
||||
return
|
||||
}
|
||||
|
||||
item := &models.AuthTenantIdp{
|
||||
Tid: p.Tid,
|
||||
Provider: p.Provider,
|
||||
Status: 1,
|
||||
}
|
||||
fillAuthIdp(item, &p)
|
||||
if p.Status != nil {
|
||||
item.Status = *p.Status
|
||||
}
|
||||
id, err := models.Orm.Insert(item)
|
||||
if err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}})
|
||||
}
|
||||
|
||||
// Edit 编辑配置(app_secret 留空表示不修改)
|
||||
// POST /platform/authIdp/edit/:id
|
||||
func (c *PlatformAuthIdpController) Edit() {
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
|
||||
return
|
||||
}
|
||||
p, ok := c.parsePayload()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var row models.AuthTenantIdp
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantIdp)).Filter("id", id).One(&row); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
|
||||
return
|
||||
}
|
||||
fillAuthIdp(&row, &p)
|
||||
if p.Status != nil {
|
||||
row.Status = *p.Status
|
||||
}
|
||||
if _, err := models.Orm.Update(&row); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
|
||||
}
|
||||
|
||||
// Delete 删除配置
|
||||
// DELETE /platform/authIdp/delete/:id
|
||||
func (c *PlatformAuthIdpController) Delete() {
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
|
||||
return
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthTenantIdp)).Filter("id", id).Delete(); err != nil {
|
||||
c.serveJSON(map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
|
||||
}
|
||||
|
||||
// CallbackHint 返回各平台需要填写的回调地址(方便复制)
|
||||
// GET /platform/authIdp/callbackHint?provider=wechat
|
||||
func (c *PlatformAuthIdpController) CallbackHint() {
|
||||
provider := strings.TrimSpace(c.GetString("provider"))
|
||||
base := jwtutil.Issuer()
|
||||
if base == "" {
|
||||
scheme := "https"
|
||||
if c.Ctx.Request.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
base = fmt.Sprintf("%s://%s/auth", scheme, c.Ctx.Request.Host)
|
||||
}
|
||||
c.serveJSON(map[string]interface{}{
|
||||
"code": 200,
|
||||
"data": map[string]interface{}{
|
||||
"callback": fmt.Sprintf("%s/third/%s/callback", base, provider),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
|
||||
// fillAuthIdp 把请求参数写入模型;留空字段表示不修改
|
||||
func fillAuthIdp(item *models.AuthTenantIdp, p *authIdpPayload) {
|
||||
if v := strings.TrimSpace(p.Name); v != "" {
|
||||
item.Name = &v
|
||||
}
|
||||
if v := strings.TrimSpace(p.AppID); v != "" {
|
||||
item.AppID = &v
|
||||
}
|
||||
if v := strings.TrimSpace(p.AppSecret); v != "" {
|
||||
item.AppSecret = &v
|
||||
}
|
||||
if v := strings.TrimSpace(p.ProxyURL); v != "" {
|
||||
item.ProxyURL = &v
|
||||
}
|
||||
if v := strings.TrimSpace(p.Scopes); v != "" {
|
||||
item.Scopes = &v
|
||||
}
|
||||
if v := strings.TrimSpace(p.AuthURL); v != "" {
|
||||
item.AuthURL = &v
|
||||
}
|
||||
if v := strings.TrimSpace(p.TokenURL); v != "" {
|
||||
item.TokenURL = &v
|
||||
}
|
||||
if v := strings.TrimSpace(p.UserinfoURL); v != "" {
|
||||
item.UserinfoURL = &v
|
||||
}
|
||||
}
|
||||
|
||||
func derefAuthIdp(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
-- =============================================================
|
||||
-- OA 薪酬管理模块表结构
|
||||
--
|
||||
-- 说明:代码里有模型定义(models/backend_oa_compensation.go),
|
||||
-- 但此前缺少建表脚本,访问薪酬页面会因表不存在返回 500。
|
||||
-- 本脚本补齐三张表,仅新建、不改动任何既有数据。
|
||||
--
|
||||
-- 注意:不显式指定 COLLATE,沿用数据库默认排序规则,避免与既有表
|
||||
-- (utf8mb4_0900_ai_ci)在 JOIN 比较字符串时报 Illegal mix of collations。
|
||||
-- =============================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------------- 1. 员工薪酬方案 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_compensation_schemes` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID',
|
||||
`scheme_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '方案名称',
|
||||
`effective_date` DATE NOT NULL COMMENT '生效日期',
|
||||
`expiry_date` DATE DEFAULT NULL COMMENT '失效日期',
|
||||
`base_salary` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '基本工资',
|
||||
`post_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '岗位津贴',
|
||||
`performance_salary` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '绩效工资',
|
||||
`transport_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '交通补贴',
|
||||
`meal_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '餐费补贴',
|
||||
`communication_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '通讯补贴',
|
||||
`social_insurance_base` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '社保基数',
|
||||
`housing_fund_base` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '公积金基数',
|
||||
`social_insurance_rate` DECIMAL(6,4) NOT NULL DEFAULT 0 COMMENT '社保个人费率',
|
||||
`housing_fund_rate` DECIMAL(6,4) NOT NULL DEFAULT 0 COMMENT '公积金个人费率',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1生效 0失效',
|
||||
`remark` TEXT,
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0,
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid` (`tid`),
|
||||
KEY `idx_employee` (`employee_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_effective` (`effective_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-员工薪酬方案';
|
||||
|
||||
-- ---------------- 2. 月度薪资单主表 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_compensation_payrolls` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID',
|
||||
`scheme_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联薪酬方案ID',
|
||||
`payroll_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '薪资月份,格式 YYYY-MM',
|
||||
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
|
||||
`department` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '部门快照',
|
||||
`position` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '职位快照',
|
||||
`base_salary` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`post_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`performance_salary` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`transport_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`meal_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`communication_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`overtime_pay` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '加班费',
|
||||
`bonus` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '奖金',
|
||||
`other_addition` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '其他加项',
|
||||
`leave_deduction` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '请假扣款',
|
||||
`late_deduction` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '迟到扣款',
|
||||
`other_deduction` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '其他扣款',
|
||||
`social_insurance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '社保个人部分',
|
||||
`housing_fund` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '公积金个人部分',
|
||||
`personal_income_tax` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '个人所得税',
|
||||
`gross_salary` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '应发合计',
|
||||
`total_deduction` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '扣款合计',
|
||||
`net_salary` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '实发工资',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已确认 2已发放',
|
||||
`confirmed_at` DATETIME DEFAULT NULL,
|
||||
`paid_at` DATETIME DEFAULT NULL,
|
||||
`remark` TEXT,
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0,
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tid_employee_month` (`tid`, `employee_id`, `payroll_month`),
|
||||
KEY `idx_tid_month` (`tid`, `payroll_month`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-月度薪资单';
|
||||
|
||||
-- ---------------- 3. 薪资单自定义增减项 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_compensation_payroll_items` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`payroll_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '薪资单ID',
|
||||
`item_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '项目名称',
|
||||
`item_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1加项 2扣项',
|
||||
`amount` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '金额',
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0,
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_payroll` (`payroll_id`),
|
||||
KEY `idx_tid` (`tid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-薪资单增减项';
|
||||
@@ -42,6 +42,10 @@ type Claims struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Amr string `json:"amr,omitempty"` // 认证方式:pwd/sms/otp/wx/...
|
||||
|
||||
// Events OIDC 事件声明,用于 Back-Channel Logout:
|
||||
// {"http://schemas.openid.net/event/backchannel-logout": {}}
|
||||
Events map[string]interface{} `json:"events,omitempty"`
|
||||
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -148,6 +152,7 @@ type TokenOptions struct {
|
||||
Subject string
|
||||
Audience []string
|
||||
Jti string // JWT ID,用于吊销(登出/踢下线)
|
||||
Events map[string]interface{} // OIDC 事件声明(Back-Channel Logout 用)
|
||||
TTL time.Duration // 留空默认 30 分钟
|
||||
Kid string
|
||||
}
|
||||
@@ -178,6 +183,7 @@ func SignToken(opt TokenOptions) (string, error) {
|
||||
Sid: opt.Sid,
|
||||
Scope: opt.Scope,
|
||||
Amr: opt.Amr,
|
||||
Events: opt.Events,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: opt.Jti,
|
||||
Subject: opt.Subject,
|
||||
|
||||
@@ -22,6 +22,19 @@ func Register() {
|
||||
beego.Router("/auth/introspect", &authctl.AuthOidcController{}, "post:Introspect")
|
||||
beego.Router("/auth/revoke", &authctl.AuthOidcController{}, "post:Revoke")
|
||||
|
||||
// ---- 第三方登录(微信/钉钉/飞书/QQ/GitHub/Google) ----
|
||||
beego.Router("/auth/third/list", &authctl.AuthThirdController{}, "get:List")
|
||||
beego.Router("/auth/third/:provider/authorize", &authctl.AuthThirdController{}, "get:Authorize")
|
||||
beego.Router("/auth/third/:provider/callback", &authctl.AuthThirdController{}, "get:Callback")
|
||||
beego.Router("/auth/third/bind", &authctl.AuthThirdController{}, "post:Bind")
|
||||
beego.Router("/auth/third/unbind", &authctl.AuthThirdController{}, "post:Unbind")
|
||||
beego.Router("/auth/third/bound", &authctl.AuthThirdController{}, "get:BoundList")
|
||||
// 自助页面(浏览器访问)
|
||||
beego.Router("/auth/bind", &authctl.AuthThirdController{}, "get:BindPage")
|
||||
beego.Router("/auth/devices", &authctl.AuthSessionController{}, "get:DevicesPage")
|
||||
beego.Router("/auth/devices/kick", &authctl.AuthSessionController{}, "get:KickPage")
|
||||
beego.Router("/auth/third/:provider/unbind", &authctl.AuthThirdController{}, "get:UnbindPage")
|
||||
|
||||
// ---- 登录与会话 ----
|
||||
// 注意:beego 同一路径重复 Router 会覆盖,故 /auth/logout 只注册一次
|
||||
beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit")
|
||||
|
||||
@@ -14,6 +14,26 @@ func Register() {
|
||||
// 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调
|
||||
RegisterWechatMpRoutes()
|
||||
|
||||
// 统一认证中心 - 租户登录策略配置
|
||||
beego.Router("/platform/authConfig/detail", &controllers.PlatformAuthConfigController{}, "get:Detail")
|
||||
beego.Router("/platform/authConfig/save", &controllers.PlatformAuthConfigController{}, "post:Save")
|
||||
beego.Router("/platform/authConfig/reset/:tid", &controllers.PlatformAuthConfigController{}, "delete:Reset")
|
||||
|
||||
// 统一认证中心 - 第三方登录配置(tid=0 全局 / tid>0 租户自带)
|
||||
beego.Router("/platform/authIdp/list", &controllers.PlatformAuthIdpController{}, "get:List")
|
||||
beego.Router("/platform/authIdp/create", &controllers.PlatformAuthIdpController{}, "post:Create")
|
||||
beego.Router("/platform/authIdp/edit/:id", &controllers.PlatformAuthIdpController{}, "post:Edit")
|
||||
beego.Router("/platform/authIdp/delete/:id", &controllers.PlatformAuthIdpController{}, "delete:Delete")
|
||||
beego.Router("/platform/authIdp/callbackHint", &controllers.PlatformAuthIdpController{}, "get:CallbackHint")
|
||||
|
||||
// 统一认证中心 - 接入应用(OIDC Client)管理
|
||||
beego.Router("/platform/authClient/list", &controllers.PlatformAuthClientController{}, "get:List")
|
||||
beego.Router("/platform/authClient/detail/:id", &controllers.PlatformAuthClientController{}, "get:Detail")
|
||||
beego.Router("/platform/authClient/create", &controllers.PlatformAuthClientController{}, "post:Create")
|
||||
beego.Router("/platform/authClient/edit/:id", &controllers.PlatformAuthClientController{}, "post:Edit")
|
||||
beego.Router("/platform/authClient/resetSecret/:id", &controllers.PlatformAuthClientController{}, "post:ResetSecret")
|
||||
beego.Router("/platform/authClient/delete/:id", &controllers.PlatformAuthClientController{}, "delete:Delete")
|
||||
|
||||
// 平台登录相关
|
||||
beego.Router("/platform/login", &controllers.PlatformAuthController{}, "post:LoginPlatform")
|
||||
beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser")
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// 全量迁移:把业务表中的老 uid(yz_system_tenant_user.uid)统一替换为
|
||||
// 认证中心的 identity_id(yz_auth_identity.id)。
|
||||
//
|
||||
// 背景:统一认证上线后,令牌里的 user_id 是 identity_id;而业务表的
|
||||
// uid / user_id / create_user_id / owner_user_id / uploader_id 等字段存的仍是
|
||||
// 老表 uid。不迁移的话用户会「找不到自己的数据」,因此做全量替换而非兼容层。
|
||||
//
|
||||
// 用法(需在 go/ 目录运行,读取 conf/app.conf):
|
||||
//
|
||||
// go run scripts/uidmigrate/migrate_uid.go -check 预检:列出受影响的表/列/行数
|
||||
// go run scripts/uidmigrate/migrate_uid.go -apply 执行迁移(记录变更日志,可回滚)
|
||||
// go run scripts/uidmigrate/migrate_uid.go -rollback 按变更日志回滚
|
||||
//
|
||||
// 说明:本脚本直接使用 database/sql,避免 beego ORM 的 Raw 取值差异。
|
||||
// 执行前请先自行备份数据库(mysqldump)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// tempOffset 中间值偏移:先把旧值搬到远离目标值的区间,避免新旧值重叠导致错改
|
||||
const tempOffset = 1000000000
|
||||
|
||||
// 需要处理的列名
|
||||
var uidColumns = []string{
|
||||
"uid", "user_id", "create_user_id", "update_user_id",
|
||||
"owner_user_id", "uploader_id", "operator_id", "tuid",
|
||||
}
|
||||
|
||||
// 不参与迁移的表
|
||||
var skipTables = map[string]bool{
|
||||
"yz_system_tenant_user": true,
|
||||
"yz_users": true,
|
||||
"yz_uid_migration_log": true,
|
||||
}
|
||||
|
||||
type colRef struct {
|
||||
Table string
|
||||
Col string
|
||||
}
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func main() {
|
||||
check := flag.Bool("check", false, "预检,不写数据")
|
||||
apply := flag.Bool("apply", false, "执行迁移")
|
||||
rollback := flag.Bool("rollback", false, "按变更日志回滚")
|
||||
flag.Parse()
|
||||
|
||||
if !*check && !*apply && !*rollback {
|
||||
fmt.Println("请指定 -check / -apply / -rollback")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := beego.LoadAppConfig("ini", "conf/app.conf"); err != nil {
|
||||
log.Printf("加载 conf/app.conf 失败(若已自动加载可忽略): %v", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
db, err = openDB()
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch {
|
||||
case *check:
|
||||
runCheck()
|
||||
case *apply:
|
||||
runApply()
|
||||
case *rollback:
|
||||
runRollback()
|
||||
}
|
||||
}
|
||||
|
||||
func openDB() (*sql.DB, error) {
|
||||
user, _ := beego.AppConfig.String("mysqluser")
|
||||
pass, _ := beego.AppConfig.String("mysqlpass")
|
||||
urls, _ := beego.AppConfig.String("mysqlurls")
|
||||
name, _ := beego.AppConfig.String("mysqldb")
|
||||
if user == "" || urls == "" || name == "" {
|
||||
return nil, fmt.Errorf("数据库配置(mysqluser/mysqlurls/mysqldb) 未正确设置")
|
||||
}
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, pass, urls, name)
|
||||
return sql.Open("mysql", dsn)
|
||||
}
|
||||
|
||||
// buildMapping 建立「老 uid → identity_id」映射:同企业(tid) 下账号/手机/邮箱 一致。
|
||||
//
|
||||
// 注意:老表是 utf8mb4_0900_ai_ci、新表是 utf8mb4_unicode_ci,直接 JOIN 比较字符串
|
||||
// 会触发 Illegal mix of collations,因此改为在 Go 侧匹配。
|
||||
func buildMapping() (map[uint64]uint64, error) {
|
||||
legacy := make([]struct {
|
||||
uid uint64
|
||||
tid uint64
|
||||
account, phone string
|
||||
email string
|
||||
}, 0)
|
||||
rows, err := db.Query(
|
||||
"SELECT uid, tid, IFNULL(account,''), IFNULL(phone,''), IFNULL(email,'') FROM yz_system_tenant_user WHERE delete_time IS NULL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var it struct {
|
||||
uid uint64
|
||||
tid uint64
|
||||
account, phone string
|
||||
email string
|
||||
}
|
||||
if err := rows.Scan(&it.uid, &it.tid, &it.account, &it.phone, &it.email); err != nil {
|
||||
continue
|
||||
}
|
||||
legacy = append(legacy, it)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
index := map[string]uint64{}
|
||||
rows2, err := db.Query(
|
||||
"SELECT identity_id, tid, IFNULL(account,''), IFNULL(phone,''), IFNULL(email,'') FROM yz_auth_tenant_user WHERE delete_time IS NULL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows2.Next() {
|
||||
var identity, tid uint64
|
||||
var account, phone, email string
|
||||
if err := rows2.Scan(&identity, &tid, &account, &phone, &email); err != nil {
|
||||
continue
|
||||
}
|
||||
if key := mergeKey(tid, account, phone, email); key != "" {
|
||||
index[key] = identity
|
||||
}
|
||||
}
|
||||
rows2.Close()
|
||||
|
||||
mapping := map[uint64]uint64{}
|
||||
for _, it := range legacy {
|
||||
key := mergeKey(it.tid, it.account, it.phone, it.email)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if id, ok := index[key]; ok && id > 0 {
|
||||
mapping[it.uid] = id
|
||||
}
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
// mergeKey 归并键:企业ID + (手机号 > 邮箱 > 账号)
|
||||
func mergeKey(tid uint64, account, phone, email string) string {
|
||||
base := fmt.Sprintf("%d:", tid)
|
||||
if v := strings.TrimSpace(phone); v != "" {
|
||||
return base + "p:" + v
|
||||
}
|
||||
if v := strings.TrimSpace(email); v != "" {
|
||||
return base + "e:" + v
|
||||
}
|
||||
if v := strings.TrimSpace(account); v != "" {
|
||||
return base + "a:" + v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findColumns() ([]colRef, error) {
|
||||
name, _ := beego.AppConfig.String("mysqldb")
|
||||
quoted := make([]string, 0, len(uidColumns))
|
||||
for _, c := range uidColumns {
|
||||
quoted = append(quoted, "'"+c+"'")
|
||||
}
|
||||
rows, err := db.Query(fmt.Sprintf(`
|
||||
SELECT TABLE_NAME, COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = '%s' AND COLUMN_NAME IN (%s)
|
||||
ORDER BY TABLE_NAME, COLUMN_NAME`, name, strings.Join(quoted, ",")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]colRef, 0)
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var t, c string
|
||||
if err := rows.Scan(&t, &c); err != nil {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if skipTables[t] || strings.Contains(t, "_bak") || strings.HasPrefix(t, "yz_auth_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, colRef{Table: t, Col: c})
|
||||
}
|
||||
log.Printf("扫描到 %d 个承载用户ID的列(跳过 %d 个)", len(out), total-len(out))
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func runCheck() {
|
||||
mapping, err := buildMapping()
|
||||
if err != nil {
|
||||
log.Fatalf("建立映射失败: %v", err)
|
||||
}
|
||||
if len(mapping) == 0 {
|
||||
log.Fatal("没有建立任何 uid → identity_id 映射,请确认认证中心数据是否已迁移")
|
||||
}
|
||||
log.Printf("映射关系: %d 个老 uid", len(mapping))
|
||||
for old, newUID := range mapping {
|
||||
log.Printf(" uid %d → identity %d", old, newUID)
|
||||
}
|
||||
|
||||
cols, err := findColumns()
|
||||
if err != nil {
|
||||
log.Fatalf("扫描列失败: %v", err)
|
||||
}
|
||||
|
||||
in := joinKeys(mapping)
|
||||
total := int64(0)
|
||||
affected := 0
|
||||
for _, c := range cols {
|
||||
var cnt int64
|
||||
err := db.QueryRow(fmt.Sprintf(
|
||||
"SELECT COUNT(*) FROM `%s` WHERE `%s` IN (%s)", c.Table, c.Col, in)).Scan(&cnt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cnt > 0 {
|
||||
log.Printf(" %-42s %-18s %d 行", c.Table, c.Col, cnt)
|
||||
total += cnt
|
||||
affected++
|
||||
}
|
||||
}
|
||||
log.Printf("合计:%d 张表的列需要更新,约 %d 行(预检完成,未写入任何数据)", affected, total)
|
||||
}
|
||||
|
||||
func runApply() {
|
||||
mapping, err := buildMapping()
|
||||
if err != nil {
|
||||
log.Fatalf("建立映射失败: %v", err)
|
||||
}
|
||||
cols, err := findColumns()
|
||||
if err != nil {
|
||||
log.Fatalf("扫描列失败: %v", err)
|
||||
}
|
||||
ensureLogTable()
|
||||
|
||||
batch := time.Now().Format("20060102150405")
|
||||
changed := 0
|
||||
for _, c := range cols {
|
||||
for oldUID, newUID := range mapping {
|
||||
if oldUID == newUID {
|
||||
continue
|
||||
}
|
||||
// 阶段一:旧值 → 临时值;阶段二:临时值 → 新值
|
||||
cnt := execUpdate(c.Table, c.Col, oldUID, oldUID+tempOffset)
|
||||
if cnt == 0 {
|
||||
continue
|
||||
}
|
||||
execUpdate(c.Table, c.Col, oldUID+tempOffset, newUID)
|
||||
saveLog(batch, c.Table, c.Col, oldUID, newUID, cnt)
|
||||
changed++
|
||||
log.Printf(" %s.%s: %d → %d(%d 行)", c.Table, c.Col, oldUID, newUID, cnt)
|
||||
}
|
||||
}
|
||||
log.Printf("迁移完成:%d 处替换,批次号 %s", changed, batch)
|
||||
log.Println("如需回滚:go run scripts/uidmigrate/migrate_uid.go -rollback")
|
||||
}
|
||||
|
||||
func runRollback() {
|
||||
rows, err := db.Query(
|
||||
"SELECT table_name, column_name, old_value, new_value FROM yz_uid_migration_log ORDER BY id DESC")
|
||||
if err != nil {
|
||||
log.Fatalf("读取变更日志失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type item struct {
|
||||
table, col string
|
||||
oldV, newV uint64
|
||||
}
|
||||
list := make([]item, 0)
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.table, &it.col, &it.oldV, &it.newV); err != nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, it)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
log.Println("没有可回滚的记录")
|
||||
return
|
||||
}
|
||||
for _, it := range list {
|
||||
// 反向:新值 → 临时值 → 旧值
|
||||
execUpdate(it.table, it.col, it.newV, it.newV+tempOffset)
|
||||
cnt := execUpdate(it.table, it.col, it.newV+tempOffset, it.oldV)
|
||||
log.Printf(" 回滚 %s.%s: %d → %d(%d 行)", it.table, it.col, it.newV, it.oldV, cnt)
|
||||
}
|
||||
log.Println("回滚完成")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
|
||||
func execUpdate(table, col string, from, to uint64) int64 {
|
||||
res, err := db.Exec(fmt.Sprintf(
|
||||
"UPDATE `%s` SET `%s` = ? WHERE `%s` = ?", table, col, col), to, from)
|
||||
if err != nil {
|
||||
log.Printf("更新失败 %s.%s (%d→%d): %v", table, col, from, to, err)
|
||||
return 0
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n
|
||||
}
|
||||
|
||||
func saveLog(batch, table, col string, oldV, newV uint64, rows int64) {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO yz_uid_migration_log (batch_no, table_name, column_name, old_value, new_value, row_count, create_time) VALUES (?,?,?,?,?,?,NOW())",
|
||||
batch, table, col, oldV, newV, rows)
|
||||
if err != nil {
|
||||
log.Printf("记录变更日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureLogTable() {
|
||||
_, _ = db.Exec(`CREATE TABLE IF NOT EXISTS yz_uid_migration_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
batch_no VARCHAR(32) NOT NULL,
|
||||
table_name VARCHAR(128) NOT NULL,
|
||||
column_name VARCHAR(64) NOT NULL,
|
||||
old_value BIGINT UNSIGNED NOT NULL,
|
||||
new_value BIGINT UNSIGNED NOT NULL,
|
||||
row_count BIGINT NOT NULL DEFAULT 0,
|
||||
create_time DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_batch (batch_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)
|
||||
}
|
||||
|
||||
func joinKeys(m map[uint64]uint64) string {
|
||||
parts := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
parts = append(parts, fmt.Sprintf("%d", k))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "0"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
@@ -70,4 +70,3 @@ func ListAdminUsers() ([]models.AdminUser, int64, error) {
|
||||
_, err = models.Orm.QueryTable(new(models.AdminUser)).OrderBy("-id").All(&rows)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,25 @@ func GetTenantUser(identityID, tid uint64) (*models.AuthTenantUser, error) {
|
||||
return &bind, nil
|
||||
}
|
||||
|
||||
// FindIdentityIDByAccount 按「企业 + 账号」查找认证中心身份ID。
|
||||
// 账号可以是企业内账号、手机号或邮箱(老登录入口按账号登录时使用)。
|
||||
func FindIdentityIDByAccount(tid uint64, account string) uint64 {
|
||||
account = strings.TrimSpace(account)
|
||||
if tid == 0 || account == "" {
|
||||
return 0
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true)
|
||||
var bind models.AuthTenantUser
|
||||
for _, field := range []string{"account", "phone", "email"} {
|
||||
if err := base.Filter(field, account).One(&bind); err == nil {
|
||||
return bind.IdentityID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// BuildProfile 组装身份概要(含可选企业列表)
|
||||
func BuildProfile(m *models.AuthIdentity) (*IdentityProfile, error) {
|
||||
tenants, err := ListTenantOptions(m.ID)
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
// Package idp 第三方身份源(微信/钉钉/飞书/QQ/GitHub/Google)统一适配层。
|
||||
//
|
||||
// 各平台 OAuth2 流程基本一致,差异只在端点地址、参数名与用户字段,
|
||||
// 因此用一份通用实现 + 预设配置覆盖,新增平台只需加一条 preset。
|
||||
package idp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// UserInfo 第三方返回的标准化用户信息
|
||||
type UserInfo struct {
|
||||
OpenID string // 该平台内的唯一 ID(必填)
|
||||
UnionID string // 跨平台唯一 ID(微信/钉钉有,GitHub/Google 用 OpenID 代替)
|
||||
Nickname string
|
||||
Avatar string
|
||||
Raw string // 原始 JSON,便于排查
|
||||
}
|
||||
|
||||
// Config 一个第三方身份源的配置
|
||||
type Config struct {
|
||||
Provider string
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
UserinfoURL string
|
||||
Scopes []string
|
||||
AppID string
|
||||
AppSecret string
|
||||
ProxyURL string // GitHub/Google 在国内服务器需要代理时填写
|
||||
|
||||
// 参数风格:微信/QQ 用 appid+secret,其余用 client_id+client_secret
|
||||
UseAppIDStyle bool
|
||||
// 用户信息接口风格:query=拼在 URL 上(微信/QQ),bearer=放 Authorization 头
|
||||
UserinfoStyle string
|
||||
|
||||
// 用户信息字段映射(一级 JSON 字段)
|
||||
OpenIDField string
|
||||
UnionIDField string
|
||||
NicknameField string
|
||||
AvatarField string
|
||||
}
|
||||
|
||||
// presets 各平台预设(端点与字段映射)
|
||||
var presets = map[string]Config{
|
||||
models.IdPWechat: {
|
||||
Provider: models.IdPWechat,
|
||||
AuthURL: "https://open.weixin.qq.com/connect/oauth2/authorize",
|
||||
TokenURL: "https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||
UserinfoURL: "https://api.weixin.qq.com/sns/userinfo",
|
||||
Scopes: []string{"snsapi_userinfo"},
|
||||
UseAppIDStyle: true,
|
||||
UserinfoStyle: "query",
|
||||
OpenIDField: "openid",
|
||||
UnionIDField: "unionid",
|
||||
NicknameField: "nickname",
|
||||
AvatarField: "headimgurl",
|
||||
},
|
||||
models.IdPQQ: {
|
||||
Provider: models.IdPQQ,
|
||||
AuthURL: "https://graph.qq.com/oauth2.0/authorize",
|
||||
TokenURL: "https://graph.qq.com/oauth2.0/token",
|
||||
UserinfoURL: "https://graph.qq.com/user/get_user_info",
|
||||
Scopes: []string{"get_user_info"},
|
||||
UseAppIDStyle: true,
|
||||
UserinfoStyle: "query",
|
||||
OpenIDField: "openid",
|
||||
NicknameField: "nickname",
|
||||
AvatarField: "figureurl_qq_2",
|
||||
},
|
||||
models.IdPGitHub: {
|
||||
Provider: models.IdPGitHub,
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
UserinfoURL: "https://api.github.com/user",
|
||||
Scopes: []string{"read:user"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "id",
|
||||
NicknameField: "login",
|
||||
AvatarField: "avatar_url",
|
||||
},
|
||||
models.IdPGoogle: {
|
||||
Provider: models.IdPGoogle,
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserinfoURL: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
Scopes: []string{"openid", "profile"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "sub",
|
||||
NicknameField: "name",
|
||||
AvatarField: "picture",
|
||||
},
|
||||
models.IdPDingTalk: {
|
||||
Provider: models.IdPDingTalk,
|
||||
AuthURL: "https://login.dingtalk.com/oauth2/auth",
|
||||
TokenURL: "https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
|
||||
UserinfoURL: "https://api.dingtalk.com/v1.0/contact/users/me",
|
||||
Scopes: []string{"openid", "profile"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "unionId",
|
||||
UnionIDField: "unionId",
|
||||
NicknameField: "nick",
|
||||
AvatarField: "avatarUrl",
|
||||
},
|
||||
models.IdPFeishu: {
|
||||
Provider: models.IdPFeishu,
|
||||
AuthURL: "https://open.feishu.cn/open-apis/authen/v1/authorize",
|
||||
TokenURL: "https://open.feishu.cn/open-apis/authen/v2/oauth/token",
|
||||
UserinfoURL: "https://open.feishu.cn/open-apis/authen/v1/user_info",
|
||||
Scopes: []string{"contact:user.base:readonly"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "open_id",
|
||||
UnionIDField: "union_id",
|
||||
NicknameField: "name",
|
||||
AvatarField: "avatar_url",
|
||||
},
|
||||
}
|
||||
|
||||
// Supported 返回支持的第三方平台列表
|
||||
func Supported() []string {
|
||||
return []string{
|
||||
models.IdPWechat, models.IdPDingTalk, models.IdPFeishu,
|
||||
models.IdPQQ, models.IdPGitHub, models.IdPGoogle,
|
||||
}
|
||||
}
|
||||
|
||||
// IsSupported 是否为已知平台
|
||||
func IsSupported(provider string) bool {
|
||||
_, ok := presets[provider]
|
||||
return ok
|
||||
}
|
||||
|
||||
// LoadConfig 读取身份源配置:
|
||||
// - tid > 0 时优先取租户自带身份源(yz_auth_tenant_idp)
|
||||
// - 取不到或 tid=0 时取平台全局配置(tid=0 的记录)
|
||||
//
|
||||
// 未配置任何记录时返回 false,调用方应提示「该登录方式未开通」。
|
||||
func LoadConfig(provider string, tid uint64) (Config, bool) {
|
||||
base, ok := presets[provider]
|
||||
if !ok {
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
var row models.AuthTenantIdp
|
||||
qs := models.Orm.QueryTable(new(models.AuthTenantIdp)).
|
||||
Filter("provider", provider).
|
||||
Filter("status", 1)
|
||||
|
||||
if tid > 0 {
|
||||
if err := qs.Filter("tid", tid).One(&row); err == nil {
|
||||
return applyDBConfig(base, &row), true
|
||||
}
|
||||
}
|
||||
// 回落到平台全局配置(tid=0)
|
||||
if err := qs.Filter("tid", 0).One(&row); err == nil {
|
||||
return applyDBConfig(base, &row), true
|
||||
}
|
||||
return base, false
|
||||
}
|
||||
|
||||
func applyDBConfig(base Config, row *models.AuthTenantIdp) Config {
|
||||
if row.AppID != nil && strings.TrimSpace(*row.AppID) != "" {
|
||||
base.AppID = strings.TrimSpace(*row.AppID)
|
||||
}
|
||||
if row.AppSecret != nil && strings.TrimSpace(*row.AppSecret) != "" {
|
||||
base.AppSecret = strings.TrimSpace(*row.AppSecret)
|
||||
}
|
||||
if row.ProxyURL != nil && strings.TrimSpace(*row.ProxyURL) != "" {
|
||||
base.ProxyURL = strings.TrimSpace(*row.ProxyURL)
|
||||
}
|
||||
if row.AuthURL != nil && strings.TrimSpace(*row.AuthURL) != "" {
|
||||
base.AuthURL = strings.TrimSpace(*row.AuthURL)
|
||||
}
|
||||
if row.TokenURL != nil && strings.TrimSpace(*row.TokenURL) != "" {
|
||||
base.TokenURL = strings.TrimSpace(*row.TokenURL)
|
||||
}
|
||||
if row.UserinfoURL != nil && strings.TrimSpace(*row.UserinfoURL) != "" {
|
||||
base.UserinfoURL = strings.TrimSpace(*row.UserinfoURL)
|
||||
}
|
||||
if row.Scopes != nil && strings.TrimSpace(*row.Scopes) != "" {
|
||||
base.Scopes = splitList(*row.Scopes)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func splitList(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if v := strings.TrimSpace(p); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildAuthURL 生成跳转到第三方授权页的地址
|
||||
func BuildAuthURL(cfg Config, redirectURI, state string) string {
|
||||
v := url.Values{}
|
||||
if cfg.UseAppIDStyle {
|
||||
v.Set("appid", cfg.AppID)
|
||||
} else {
|
||||
v.Set("client_id", cfg.AppID)
|
||||
}
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", strings.Join(cfg.Scopes, ","))
|
||||
v.Set("state", state)
|
||||
|
||||
addr := cfg.AuthURL + "?" + v.Encode()
|
||||
if cfg.Provider == models.IdPWechat {
|
||||
// 微信要求在 hash 后带 #wechat_redirect
|
||||
return addr + "#wechat_redirect"
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// Exchange 用授权码换取用户信息
|
||||
func Exchange(cfg Config, code string) (*UserInfo, error) {
|
||||
if cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return nil, errors.New("该登录方式尚未配置,请联系管理员")
|
||||
}
|
||||
|
||||
// 1. code 换 access_token
|
||||
v := url.Values{}
|
||||
if cfg.UseAppIDStyle {
|
||||
v.Set("appid", cfg.AppID)
|
||||
v.Set("secret", cfg.AppSecret)
|
||||
} else {
|
||||
v.Set("client_id", cfg.AppID)
|
||||
v.Set("client_secret", cfg.AppSecret)
|
||||
}
|
||||
v.Set("code", code)
|
||||
v.Set("grant_type", "authorization_code")
|
||||
|
||||
tokenBody, err := post(cfg, cfg.TokenURL, v, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenMap := parseMap(tokenBody)
|
||||
accessToken := firstStr(tokenMap, "access_token")
|
||||
if accessToken == "" {
|
||||
return nil, fmt.Errorf("换取令牌失败: %s", truncate(tokenBody, 200))
|
||||
}
|
||||
openID := firstStr(tokenMap, "openid", "unionId", "open_id", "sub", "id")
|
||||
|
||||
// 2. 取用户信息
|
||||
var infoMap map[string]interface{}
|
||||
if cfg.UserinfoStyle == "query" {
|
||||
q := url.Values{}
|
||||
q.Set("access_token", accessToken)
|
||||
if openID != "" {
|
||||
q.Set("openid", openID)
|
||||
}
|
||||
// QQ 需要额外带 oauth_consumer_key
|
||||
if cfg.Provider == models.IdPQQ {
|
||||
q.Set("oauth_consumer_key", cfg.AppID)
|
||||
}
|
||||
body, err := get(cfg, cfg.UserinfoURL+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoMap = parseMap(body)
|
||||
} else {
|
||||
body, err := get(cfg, cfg.UserinfoURL, map[string]string{
|
||||
"Authorization": "Bearer " + accessToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoMap = parseMap(body)
|
||||
// 飞书把数据包在 data 里
|
||||
if d, ok := infoMap["data"].(map[string]interface{}); ok {
|
||||
infoMap = d
|
||||
}
|
||||
}
|
||||
|
||||
info := &UserInfo{Raw: truncate(mustJSON(infoMap), 2000)}
|
||||
info.OpenID = strField(infoMap, cfg.OpenIDField)
|
||||
if cfg.UnionIDField != "" {
|
||||
info.UnionID = strField(infoMap, cfg.UnionIDField)
|
||||
}
|
||||
info.Nickname = strField(infoMap, cfg.NicknameField)
|
||||
info.Avatar = strField(infoMap, cfg.AvatarField)
|
||||
|
||||
if info.OpenID == "" && openID != "" {
|
||||
info.OpenID = openID
|
||||
}
|
||||
if info.OpenID == "" {
|
||||
return nil, errors.New("未能获取第三方账号标识")
|
||||
}
|
||||
if info.UnionID == "" {
|
||||
info.UnionID = info.OpenID
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HTTP
|
||||
|
||||
func client(cfg Config) *http.Client {
|
||||
c := &http.Client{Timeout: 15 * time.Second}
|
||||
if cfg.ProxyURL != "" {
|
||||
if u, err := url.Parse(cfg.ProxyURL); err == nil {
|
||||
c.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func post(cfg Config, target string, form url.Values, headers map[string]string) (string, error) {
|
||||
req, err := http.NewRequest("POST", target, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cfg.Provider == models.IdPGitHub {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client(cfg).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求第三方失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("第三方返回异常(%d): %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func get(cfg Config, target string, headers map[string]string) (string, error) {
|
||||
req, err := http.NewRequest("GET", target, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client(cfg).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求第三方失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("第三方返回异常(%d): %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 解析工具
|
||||
|
||||
// parseMap 兼容 JSON 与 querystring 两种响应(微信/QQ 早期接口返回 form 格式)
|
||||
func parseMap(body string) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
if err := json.Unmarshal([]byte(body), &out); err == nil {
|
||||
return out
|
||||
}
|
||||
if values, err := url.ParseQuery(body); err == nil {
|
||||
for k, v := range values {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstStr(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t != "" {
|
||||
return t
|
||||
}
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strField(m map[string]interface{}, field string) string {
|
||||
if field == "" {
|
||||
return ""
|
||||
}
|
||||
return firstStr(m, field)
|
||||
}
|
||||
|
||||
func mustJSON(m map[string]interface{}) string {
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/pkg/passwordutil"
|
||||
)
|
||||
|
||||
@@ -127,9 +128,9 @@ func PasswordLogin(account, password, clientID, ip, userAgent string) (*LoginRes
|
||||
ClientID: clientID,
|
||||
Sid: sess.Sid,
|
||||
Username: profile.Nickname,
|
||||
UserType: "tenant",
|
||||
Amr: AmrPwd,
|
||||
AccessTTL: accessTTL,
|
||||
// 不指定 UserType:由 IssueTokens 按应用编码给出 backend / app
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -184,7 +185,6 @@ func ChooseTenant(sid string, tid uint64, clientID string) (*TokenPair, *Identit
|
||||
ClientID: clientID,
|
||||
Sid: sid,
|
||||
Username: profile.Nickname,
|
||||
UserType: "tenant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -262,7 +262,32 @@ func writeLoginLog(tid uint64, identityID uint64, account, userName, clientID, l
|
||||
return err
|
||||
}
|
||||
|
||||
// Logout 登出:吊销刷新令牌、会话与访问令牌
|
||||
// Logout 登出:吊销刷新令牌、会话与访问令牌,并通知其他应用(单点登出)
|
||||
func Logout(accessToken, refreshToken string) error {
|
||||
return RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout)
|
||||
err := RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout)
|
||||
|
||||
// 单点登出:通知该用户在其他应用的会话(如后台登出后 APP 同步下线)
|
||||
identityID := uint64(0)
|
||||
if claims, e := jwtutil.ParseTokenRaw(accessToken); e == nil && claims.UserID > 0 {
|
||||
identityID = uint64(claims.UserID)
|
||||
}
|
||||
if identityID == 0 && refreshToken != "" {
|
||||
if rt, e := findRefreshToken(refreshToken); e == nil {
|
||||
identityID = rt.IdentityID
|
||||
}
|
||||
}
|
||||
if identityID > 0 {
|
||||
NotifyBackchannelLogout(identityID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// findRefreshToken 按明文查找刷新令牌记录
|
||||
func findRefreshToken(plain string) (*models.AuthRefreshToken, error) {
|
||||
var rt models.AuthRefreshToken
|
||||
if err := models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||
Filter("token_hash", hashToken(plain)).One(&rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rt, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
)
|
||||
|
||||
// BackchannelLogoutEvent OIDC 标准登出事件声明
|
||||
const BackchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
|
||||
|
||||
// logoutTarget 需要收到登出通知的应用
|
||||
type logoutTarget struct {
|
||||
ClientID string
|
||||
Endpoint string
|
||||
Sid string
|
||||
Identity uint64
|
||||
}
|
||||
|
||||
// NotifyBackchannelLogout 单点登出:通知该用户已登录的所有应用。
|
||||
//
|
||||
// 流程:查库收集目标应用(同步)→ 异步逐个 POST logout_token。
|
||||
// 各应用收到后应清除本地会话,否则用户在这边登出了,其他应用仍显示已登录。
|
||||
//
|
||||
// 注意:查库必须在请求上下文内同步完成(beego 全局 Ormer 不适合跨 goroutine 使用),
|
||||
// 异步部分只做 HTTP 通知,不再触碰数据库。
|
||||
func NotifyBackchannelLogout(identityID uint64) {
|
||||
targets, err := collectLogoutTargets(identityID)
|
||||
if err != nil || len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for _, t := range targets {
|
||||
if err := sendLogoutToken(t); err != nil {
|
||||
log.Printf("[auth] 单点登出通知失败 client=%s: %v", t.ClientID, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// collectLogoutTargets 收集该用户当前活跃会话涉及的应用(按 client_id 去重)
|
||||
func collectLogoutTargets(identityID uint64) ([]logoutTarget, error) {
|
||||
var sessions []models.AuthSession
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("revoked", 0).
|
||||
All(&sessions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
targets := make([]logoutTarget, 0)
|
||||
for _, s := range sessions {
|
||||
if s.ClientID == "" || seen[s.ClientID] {
|
||||
continue
|
||||
}
|
||||
var client models.AuthClient
|
||||
if err := models.Orm.QueryTable(new(models.AuthClient)).
|
||||
Filter("client_id", s.ClientID).One(&client); err != nil {
|
||||
continue
|
||||
}
|
||||
if client.BackchannelLogoutURI == nil || strings.TrimSpace(*client.BackchannelLogoutURI) == "" {
|
||||
continue
|
||||
}
|
||||
seen[s.ClientID] = true
|
||||
targets = append(targets, logoutTarget{
|
||||
ClientID: s.ClientID,
|
||||
Endpoint: strings.TrimSpace(*client.BackchannelLogoutURI),
|
||||
Sid: s.Sid,
|
||||
Identity: identityID,
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// sendLogoutToken 按 OIDC Back-Channel Logout 规范发送 logout_token
|
||||
func sendLogoutToken(t logoutTarget) error {
|
||||
token, err := jwtutil.SignToken(jwtutil.TokenOptions{
|
||||
Alg: jwtutil.AlgRS256,
|
||||
UserID: int(t.Identity),
|
||||
Subject: fmt.Sprintf("%d", t.Identity),
|
||||
Audience: []string{t.ClientID},
|
||||
ClientID: t.ClientID,
|
||||
Sid: t.Sid,
|
||||
Jti: uuid.NewString(),
|
||||
Events: map[string]interface{}{BackchannelLogoutEvent: map[string]interface{}{}},
|
||||
TTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("logout_token", token)
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.PostForm(t.Endpoint, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("应用返回状态码 %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -46,6 +47,10 @@ type TokenIssue struct {
|
||||
|
||||
// IssueTokens 签发访问令牌与刷新令牌。
|
||||
// 刷新令牌明文只在本次返回,库中仅存哈希。
|
||||
//
|
||||
// 令牌中的 user_id 为认证中心 identity_id(yz_auth_identity.id)。
|
||||
// 业务表(文件/客户/合同/日程等)中的 uid 已由 scripts/uidmigrate 全量迁移为
|
||||
// 同一套 ID,因此无需任何兼容换算。
|
||||
func IssueTokens(opt TokenIssue) (*TokenPair, error) {
|
||||
accessTTL := opt.AccessTTL
|
||||
if accessTTL <= 0 {
|
||||
@@ -56,13 +61,27 @@ func IssueTokens(opt TokenIssue) (*TokenPair, error) {
|
||||
refreshTTL = 2592000
|
||||
}
|
||||
|
||||
// 全量迁移后,令牌中的 user_id 统一为认证中心的 identity_id
|
||||
userID := int(opt.IdentityID)
|
||||
|
||||
// userType 需与现有业务接口的判定保持一致:
|
||||
// 后端接口普遍要求 user_type 为 "backend"(租户后台)或 "app"(移动端),
|
||||
// 若签发 "tenant" 会被判为无权访问。这里按应用编码给出默认值。
|
||||
userType := opt.UserType
|
||||
if userType == "" {
|
||||
userType = "backend"
|
||||
if opt.ClientID == "yz-uniapp" {
|
||||
userType = "app"
|
||||
}
|
||||
}
|
||||
|
||||
jti := uuid.NewString()
|
||||
access, err := jwtutil.SignToken(jwtutil.TokenOptions{
|
||||
Alg: jwtutil.AlgRS256,
|
||||
UserID: int(opt.IdentityID),
|
||||
UserID: userID,
|
||||
Username: opt.Username,
|
||||
TenantID: int(opt.Tid),
|
||||
UserType: opt.UserType,
|
||||
UserType: userType,
|
||||
ClientID: opt.ClientID,
|
||||
Sid: opt.Sid,
|
||||
Amr: opt.Amr,
|
||||
@@ -159,6 +178,46 @@ func RefreshTokens(plain, clientID string) (*TokenPair, error) {
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// FindLegacyUID 通过「身份 + 企业」在老表 yz_system_tenant_user 中找到对应 uid。
|
||||
//
|
||||
// 双轨期(老登录与统一认证并行)下业务接口仍以老 uid 识别用户,
|
||||
// 因此签发令牌、返回用户信息时都要换算回老 uid。
|
||||
// 匹配顺序:账号 → 手机号 → 邮箱;都匹配不到返回 0。
|
||||
func FindLegacyUID(tid, identityID uint64) uint64 {
|
||||
if tid == 0 || identityID == 0 {
|
||||
return 0
|
||||
}
|
||||
var bind models.AuthTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&bind); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("tid", tid)
|
||||
match := func(field string, value *string) uint64 {
|
||||
if value == nil || strings.TrimSpace(*value) == "" {
|
||||
return 0
|
||||
}
|
||||
var row models.SystemTenantUser
|
||||
if err := qs.Filter(field, strings.TrimSpace(*value)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row); err == nil {
|
||||
return row.Uid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if uid := match("account", bind.Account); uid > 0 {
|
||||
return uid
|
||||
}
|
||||
if uid := match("phone", bind.Phone); uid > 0 {
|
||||
return uid
|
||||
}
|
||||
return match("email", bind.Email)
|
||||
}
|
||||
|
||||
// RevokeTokenPair 登出:吊销刷新令牌、会话,并把 access token 的 jti 加入黑名单。
|
||||
func RevokeTokenPair(plain, accessToken, reason string) error {
|
||||
if plain != "" {
|
||||
|
||||
@@ -48,4 +48,3 @@ func CheckUserPermission(userID int, permission string) (bool, error) {
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/pkg/passwordutil"
|
||||
authsvc "server/services/auth"
|
||||
)
|
||||
|
||||
type PlatformLoginUser struct {
|
||||
@@ -133,7 +134,14 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
|
||||
|
||||
tenantID := int(tenant.ID)
|
||||
const userType = "backend"
|
||||
token, err := jwtutil.GenerateToken(int(tenantUser.Uid), account, tenantID, userType)
|
||||
|
||||
// 令牌中的 user_id 统一使用认证中心 identity_id(与统一登录签发的一致)。
|
||||
// 业务表的 uid 已全量迁移为同一套 ID,因此老登录入口无需停用也不会错位。
|
||||
userID := int(tenantUser.Uid)
|
||||
if identityID := authsvc.FindIdentityIDByAccount(tenant.ID, account); identityID > 0 {
|
||||
userID = int(identityID)
|
||||
}
|
||||
token, err := jwtutil.GenerateToken(userID, account, tenantID, userType)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>第三方账号绑定 - 统一认证中心</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.card {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||
width: 460px; max-width: 92vw; padding: 36px 32px;
|
||||
}
|
||||
h1 { font-size: 20px; color: #1a1a2e; margin-bottom: 6px; }
|
||||
.sub { font-size: 13px; color: #8a8fa3; margin-bottom: 22px; }
|
||||
.msg {
|
||||
padding: 10px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 16px;
|
||||
}
|
||||
.msg.ok { background: #f0fff4; border: 1px solid #b7ebc8; color: #189a4a; }
|
||||
.msg.err { background: #fff2f0; border: 1px solid #ffccc7; color: #cf1322; }
|
||||
.item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border: 1px solid #e4e7ee; border-radius: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.info .name { font-size: 15px; color: #1a1a2e; font-weight: 500; }
|
||||
.info .meta { font-size: 12px; color: #8a8fa3; margin-top: 3px; }
|
||||
.btn {
|
||||
padding: 7px 16px; border-radius: 6px; border: none; cursor: pointer;
|
||||
font-size: 13px; white-space: nowrap;
|
||||
}
|
||||
.btn-bind { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
||||
.btn-unbind { background: #fff; border: 1px solid #dcdfe8; color: #8a8fa3; }
|
||||
.btn-unbind:hover { border-color: #cf1322; color: #cf1322; }
|
||||
.empty { text-align: center; color: #8a8fa3; font-size: 13px; padding: 24px 0; }
|
||||
.back { text-align: center; margin-top: 18px; }
|
||||
.back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>第三方账号绑定</h1>
|
||||
<div class="sub">绑定后可使用对应平台一键登录</div>
|
||||
|
||||
{{if .Success}}<div class="msg ok">{{.Success}}</div>{{end}}
|
||||
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if .Providers}}
|
||||
{{range .Providers}}
|
||||
<div class="item">
|
||||
<div class="info">
|
||||
<div class="name">{{.name}}</div>
|
||||
{{if .bound}}
|
||||
<div class="meta">已绑定{{if .nickname}} · {{.nickname}}{{end}}</div>
|
||||
{{else}}
|
||||
<div class="meta">未绑定</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .bound}}
|
||||
<a class="btn btn-unbind" href="/auth/third/{{.provider}}/unbind">解绑</a>
|
||||
{{else}}
|
||||
<a class="btn btn-bind" href="/auth/third/{{.provider}}/authorize?action=bind">绑定</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty">尚未开通任何第三方登录方式</div>
|
||||
{{end}}
|
||||
|
||||
<div class="back"><a href="/auth/login">返回登录</a></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>在线设备 - 统一认证中心</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.card {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||
width: 560px; max-width: 92vw; padding: 36px 32px;
|
||||
}
|
||||
h1 { font-size: 20px; color: #1a1a2e; margin-bottom: 6px; }
|
||||
.sub { font-size: 13px; color: #8a8fa3; margin-bottom: 20px; }
|
||||
.msg { padding: 10px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 16px; }
|
||||
.msg.ok { background: #f0fff4; border: 1px solid #b7ebc8; color: #189a4a; }
|
||||
.msg.err { background: #fff2f0; border: 1px solid #ffccc7; color: #cf1322; }
|
||||
.item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border: 1px solid #e4e7ee; border-radius: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.item.current { border-color: #667eea; background: #f7f8ff; }
|
||||
.device { font-size: 15px; color: #1a1a2e; font-weight: 500; }
|
||||
.meta { font-size: 12px; color: #8a8fa3; margin-top: 4px; line-height: 1.6; }
|
||||
.tag {
|
||||
display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px;
|
||||
background: #eef1ff; color: #667eea; margin-left: 6px;
|
||||
}
|
||||
.btn {
|
||||
padding: 7px 14px; border-radius: 6px; border: 1px solid #dcdfe8;
|
||||
background: #fff; color: #8a8fa3; font-size: 13px; cursor: pointer; text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: #cf1322; color: #cf1322; }
|
||||
.empty { text-align: center; color: #8a8fa3; font-size: 13px; padding: 30px 0; }
|
||||
.footer { text-align: center; margin-top: 18px; display: flex; gap: 18px; justify-content: center; }
|
||||
.footer a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>在线设备</h1>
|
||||
<div class="sub">这里是当前账号的登录设备,发现异常可立即下线</div>
|
||||
|
||||
{{if .Success}}<div class="msg ok">{{.Success}}</div>{{end}}
|
||||
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if .Sessions}}
|
||||
{{range .Sessions}}
|
||||
<div class="item {{if .Current}}current{{end}}">
|
||||
<div>
|
||||
<div class="device">
|
||||
{{.Device}}
|
||||
{{if .Current}}<span class="tag">当前设备</span>{{end}}
|
||||
{{if .Expired}}<span class="tag">已过期</span>{{end}}
|
||||
</div>
|
||||
<div class="meta">
|
||||
应用:{{.Client}} · IP:{{.IP}}<br>
|
||||
登录:{{.LoginAt}} · 最近活跃:{{.LastAt}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .Current}}
|
||||
<span class="btn" style="opacity:.5;cursor:default">使用中</span>
|
||||
{{else}}
|
||||
<a class="btn" href="/auth/devices/kick?sid={{.Sid}}"
|
||||
onclick="return confirm('确定将该设备下线吗?')">下线</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty">暂无在线设备</div>
|
||||
{{end}}
|
||||
|
||||
<div class="footer">
|
||||
<a href="/auth/bind">第三方账号</a>
|
||||
<a href="/auth/logout">退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -47,6 +47,21 @@
|
||||
.tenant-meta { font-size: 12px; color: #8a8fa3; margin-top: 3px; }
|
||||
.tenant-arrow { color: #c0c4d0; font-size: 18px; }
|
||||
.tip { text-align: center; font-size: 12px; color: #8a8fa3; margin-top: 20px; }
|
||||
.divider {
|
||||
position: relative; text-align: center; margin: 24px 0 14px;
|
||||
font-size: 12px; color: #a0a5b5;
|
||||
}
|
||||
.divider::before {
|
||||
content: ''; position: absolute; top: 50%; left: 0; right: 0;
|
||||
height: 1px; background: #e4e7ee;
|
||||
}
|
||||
.divider span { position: relative; background: #fff; padding: 0 12px; }
|
||||
.third-list { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
||||
.third-btn {
|
||||
padding: 8px 16px; border: 1px solid #dcdfe8; border-radius: 20px;
|
||||
background: #fff; font-size: 13px; color: #5a6072; cursor: pointer;
|
||||
}
|
||||
.third-btn:hover { border-color: #667eea; color: #667eea; }
|
||||
.back { text-align: center; margin-top: 14px; }
|
||||
.back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
@@ -87,6 +102,12 @@
|
||||
</div>
|
||||
<button class="btn" id="submitBtn" type="submit">登 录</button>
|
||||
</form>
|
||||
|
||||
<!-- 第三方登录 -->
|
||||
<div class="third-area" id="thirdArea" style="display:none">
|
||||
<div class="divider"><span>其他登录方式</span></div>
|
||||
<div class="third-list" id="thirdList"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="tip">统一身份认证 · 一次登录,全平台通行</div>
|
||||
@@ -102,6 +123,34 @@
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
// 加载已开通的第三方登录方式
|
||||
function loadThirdProviders() {
|
||||
fetch('/auth/third/list')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var list = (res && res.data) || [];
|
||||
var enabled = list.filter(function (i) { return i.enabled; });
|
||||
if (!enabled.length) return;
|
||||
var box = document.getElementById('thirdList');
|
||||
enabled.forEach(function (item) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'third-btn';
|
||||
btn.textContent = item.name;
|
||||
btn.onclick = function () { startThird(item.provider); };
|
||||
box.appendChild(btn);
|
||||
});
|
||||
document.getElementById('thirdArea').style.display = 'block';
|
||||
})
|
||||
.catch(function () { /* 静默失败,不影响账号密码登录 */ });
|
||||
}
|
||||
|
||||
function startThird(provider) {
|
||||
window.location.href = '/auth/third/' + encodeURIComponent(provider) +
|
||||
'/authorize?redirect=' + encodeURIComponent(REDIRECT);
|
||||
}
|
||||
|
||||
loadThirdProviders();
|
||||
|
||||
function submitLogin(e) {
|
||||
e.preventDefault();
|
||||
var account = document.getElementById('account').value.trim();
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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,6 +21,27 @@ const staticMainChildren = [
|
||||
component: () => import("@/views/system/email/index.vue"),
|
||||
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",
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -28,8 +28,10 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 4000,
|
||||
// 开发时前端在 4000,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=9000)
|
||||
port: 4400,
|
||||
// 端口被占用时直接报错,不再自动漂移到其他端口(避免回跳地址与实际端口不一致)
|
||||
strictPort: true,
|
||||
// 开发时前端在 4400,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=9000)
|
||||
proxy: {
|
||||
"/platform": {
|
||||
target: "http://127.0.0.1:9000",
|
||||
|
||||
@@ -13,6 +13,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 4403,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
// H5 开发:api/config.js 默认 baseURL 为 /proxy-api,转发到 Go 后端
|
||||
'/proxy-api': {
|
||||
|
||||
@@ -21,7 +21,8 @@ export default defineConfig({
|
||||
server: {
|
||||
open: true,
|
||||
host: '127.0.0.1',
|
||||
port: 4002,
|
||||
port: 4402,
|
||||
strictPort: true,
|
||||
hmr: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user