做统一认证登录
This commit is contained in:
@@ -69,3 +69,5 @@ go/conf
|
|||||||
.mimocode/package.json
|
.mimocode/package.json
|
||||||
.mimocode/package-lock.json
|
.mimocode/package-lock.json
|
||||||
.mimocode/bun.lock
|
.mimocode/bun.lock
|
||||||
|
|
||||||
|
docs/sql/
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHashHistory } from "vue-router";
|
import { createRouter, createWebHashHistory } from "vue-router";
|
||||||
import { convertMenusToRoutes } from "./dynamicRoutes";
|
import { convertMenusToRoutes } from "./dynamicRoutes";
|
||||||
|
import { isSSOEnabled, redirectToAuthorize } from "@/utils/authClient";
|
||||||
|
|
||||||
// 静态子路由:需要在 Main 框架内显示的页面
|
// 静态子路由:需要在 Main 框架内显示的页面
|
||||||
const staticMainChildren = [
|
const staticMainChildren = [
|
||||||
@@ -146,6 +147,13 @@ const staticRoutes = [
|
|||||||
component: () => import("@/views/login/forget.vue"),
|
component: () => import("@/views/login/forget.vue"),
|
||||||
meta: { requiresAuth: false }
|
meta: { requiresAuth: false }
|
||||||
},
|
},
|
||||||
|
// 统一认证中心回跳页(接收 code 换取令牌,页面自身不展示业务内容)
|
||||||
|
{
|
||||||
|
path: "/auth/callback",
|
||||||
|
name: "AuthCallback",
|
||||||
|
component: () => import("@/views/auth/callback.vue"),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/home",
|
path: "/home",
|
||||||
name: "Home",
|
name: "Home",
|
||||||
@@ -304,7 +312,7 @@ function findFirstValidRoute(routes) {
|
|||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const publicPaths = ["/login", "/register", "/forget"];
|
const publicPaths = ["/login", "/register", "/forget", "/auth/callback"];
|
||||||
|
|
||||||
if (publicPaths.includes(to.path)) {
|
if (publicPaths.includes(to.path)) {
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -319,6 +327,11 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
// 统一认证模式:跳认证中心登录(未开启时走原有本地登录页)
|
||||||
|
if (isSSOEnabled()) {
|
||||||
|
await redirectToAuthorize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
next({ path: "/login", query: { redirect: to.path } });
|
next({ path: "/login", query: { redirect: to.path } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { getCurrentUser } from '@/api/login'
|
import { getCurrentUser } from '@/api/login'
|
||||||
|
import { isSSOEnabled, logoutSSO } from '@/utils/authClient'
|
||||||
|
|
||||||
// 用户信息类型
|
// 用户信息类型
|
||||||
const defaultUser = {
|
const defaultUser = {
|
||||||
@@ -93,6 +94,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
Object.assign(user, defaultUser)
|
Object.assign(user, defaultUser)
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('userInfo')
|
localStorage.removeItem('userInfo')
|
||||||
|
// 统一认证模式:吊销令牌并跳认证中心完成单点登出(所有调用点自动生效)
|
||||||
|
if (isSSOEnabled()) {
|
||||||
|
logoutSSO()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查认证状态
|
// 检查认证状态
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 统一认证中心(UAC)客户端 —— OIDC Authorization Code + PKCE
|
||||||
|
*
|
||||||
|
* 接入方式:在 .env 中配置
|
||||||
|
* VITE_AUTH_MODE=sso 开启统一认证(不配置则走原有 /backend/login)
|
||||||
|
* VITE_AUTH_BASE=https://api.yunzer.cn/auth
|
||||||
|
* VITE_AUTH_CLIENT_ID=yz-backend
|
||||||
|
* VITE_AUTH_REDIRECT_URI=https://back.yunzer.cn/#/auth/callback
|
||||||
|
*
|
||||||
|
* 说明:token 仍存在 localStorage(沿用现有 utils/request.js 与 stores/auth.js),
|
||||||
|
* 因此接入后业务代码无需改动即可带上 Authorization 头。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE || "";
|
||||||
|
const CLIENT_ID = import.meta.env.VITE_AUTH_CLIENT_ID || "yz-backend";
|
||||||
|
const REDIRECT_URI =
|
||||||
|
import.meta.env.VITE_AUTH_REDIRECT_URI || `${window.location.origin}/#/auth/callback`;
|
||||||
|
const POST_LOGOUT_URI =
|
||||||
|
import.meta.env.VITE_AUTH_POST_LOGOUT_URI || `${window.location.origin}/#/login`;
|
||||||
|
|
||||||
|
const ACCESS_KEY = "token"; // 与现有代码保持一致
|
||||||
|
const REFRESH_KEY = "auth_refresh_token";
|
||||||
|
const SID_KEY = "auth_sid";
|
||||||
|
|
||||||
|
/** 是否启用统一认证(默认关闭,保持原有登录方式) */
|
||||||
|
export function isSSOEnabled() {
|
||||||
|
return import.meta.env.VITE_AUTH_MODE === "sso" && !!AUTH_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- PKCE
|
||||||
|
|
||||||
|
function randomString(len = 64) {
|
||||||
|
const arr = new Uint8Array(len);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
return Array.from(arr, (b) => ("0" + b.toString(16)).slice(-2)).join("").slice(0, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(plain) {
|
||||||
|
return crypto.subtle.digest("SHA-256", new TextEncoder().encode(plain));
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url(buf) {
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePKCE() {
|
||||||
|
const verifier = randomString(64);
|
||||||
|
const challenge = base64url(await sha256(verifier));
|
||||||
|
return { verifier, challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 令牌存储
|
||||||
|
|
||||||
|
export function setTokens(data) {
|
||||||
|
if (data?.access_token) localStorage.setItem(ACCESS_KEY, data.access_token);
|
||||||
|
if (data?.refresh_token) localStorage.setItem(REFRESH_KEY, data.refresh_token);
|
||||||
|
if (data?.sid) localStorage.setItem(SID_KEY, data.sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken() {
|
||||||
|
return localStorage.getItem(REFRESH_KEY) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTokens() {
|
||||||
|
localStorage.removeItem(ACCESS_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_KEY);
|
||||||
|
localStorage.removeItem(SID_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken() {
|
||||||
|
return localStorage.getItem(ACCESS_KEY) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 主流程
|
||||||
|
|
||||||
|
/** 跳转认证中心登录(带 PKCE 与 state) */
|
||||||
|
export async function redirectToAuthorize() {
|
||||||
|
const { verifier, challenge } = await generatePKCE();
|
||||||
|
sessionStorage.setItem("pkce_verifier", verifier);
|
||||||
|
const state = randomString(24);
|
||||||
|
sessionStorage.setItem("oidc_state", state);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid profile tenant",
|
||||||
|
state,
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
});
|
||||||
|
window.location.href = `${AUTH_BASE}/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理认证中心回跳:用 code 换令牌 */
|
||||||
|
export async function handleAuthorizeCallback(query = {}) {
|
||||||
|
const code = query.code;
|
||||||
|
const state = query.state;
|
||||||
|
const savedState = sessionStorage.getItem("oidc_state");
|
||||||
|
const verifier = sessionStorage.getItem("pkce_verifier");
|
||||||
|
|
||||||
|
if (!code) throw new Error("缺少授权码");
|
||||||
|
if (savedState && state !== savedState) throw new Error("state 校验失败");
|
||||||
|
if (!verifier) throw new Error("PKCE 校验信息丢失,请重新登录");
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
code_verifier: verifier,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${AUTH_BASE}/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.access_token) {
|
||||||
|
throw new Error(data.error_description || data.error || "换取令牌失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.removeItem("pkce_verifier");
|
||||||
|
sessionStorage.removeItem("oidc_state");
|
||||||
|
setTokens(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 静默刷新访问令牌 */
|
||||||
|
export async function refreshAccessToken() {
|
||||||
|
const refresh = getRefreshToken();
|
||||||
|
if (!refresh) throw new Error("无刷新令牌");
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refresh,
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
});
|
||||||
|
const res = await fetch(`${AUTH_BASE}/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.access_token) throw new Error("刷新令牌失败");
|
||||||
|
setTokens(data);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单点登出:吊销令牌后回认证中心登出页 */
|
||||||
|
export async function logoutSSO() {
|
||||||
|
const token = getAccessToken();
|
||||||
|
try {
|
||||||
|
await fetch(`${AUTH_BASE}/revoke`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ token, refresh_token: getRefreshToken() }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 吊销失败也要继续登出,避免卡在本端
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
post_logout_redirect_uri: POST_LOGOUT_URI,
|
||||||
|
});
|
||||||
|
window.location.href = `${AUTH_BASE}/logout?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取当前登录用户(认证中心视角) */
|
||||||
|
export async function fetchUserInfo() {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/userinfo`, {
|
||||||
|
headers: { Authorization: `Bearer ${getAccessToken()}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("获取用户信息失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换企业(一人多企业场景) */
|
||||||
|
export async function switchTenant(tid) {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/switch-tenant`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${getAccessToken()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ tid, client_id: CLIENT_ID }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.code !== 200) throw new Error(data.msg || "切换企业失败");
|
||||||
|
if (data.data?.tokens) setTokens(data.data.tokens);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { isSSOEnabled, refreshAccessToken, clearTokens } from '@/utils/authClient';
|
||||||
|
|
||||||
// 获取API基础URL,添加调试信息
|
// 获取API基础URL,添加调试信息
|
||||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
|
const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
|
||||||
@@ -40,13 +41,27 @@ service.interceptors.response.use(
|
|||||||
response => {
|
response => {
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
error => {
|
async error => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
|
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
|
||||||
// 避免页面只显示 "Request failed with status code xxx"
|
// 避免页面只显示 "Request failed with status code xxx"
|
||||||
const bizMsg = error.response.data?.msg || error.response.data?.message;
|
const bizMsg = error.response.data?.msg || error.response.data?.message;
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401:
|
case 401:
|
||||||
|
// 统一认证模式:先用 refresh_token 静默换取新令牌并重放原请求,
|
||||||
|
// 刷新失败(令牌过期/被吊销)才真正登出。
|
||||||
|
if (isSSOEnabled()) {
|
||||||
|
try {
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
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无效'));
|
||||||
|
}
|
||||||
|
}
|
||||||
console.error('未授权,请重新登录');
|
console.error('未授权,请重新登录');
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('userInfo');
|
localStorage.removeItem('userInfo');
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-callback">
|
||||||
|
<div class="box">
|
||||||
|
<div v-if="error" class="error">
|
||||||
|
<p class="title">登录失败</p>
|
||||||
|
<p class="msg">{{ error }}</p>
|
||||||
|
<button class="btn" @click="retry">重新登录</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>正在完成登录,请稍候…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { handleAuthorizeCallback, redirectToAuthorize } from "@/utils/authClient";
|
||||||
|
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
async function finish() {
|
||||||
|
// hash 路由:参数位于 location.hash 之后,这里统一从 URL 中取出
|
||||||
|
const raw = window.location.href;
|
||||||
|
const queryStr = raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : "";
|
||||||
|
const query = Object.fromEntries(new URLSearchParams(queryStr));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleAuthorizeCallback(query);
|
||||||
|
// 登录成功:清掉 URL 上的 code/state,进入首页
|
||||||
|
window.location.href = "#/home";
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.message || "登录失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
redirectToAuthorize();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(finish);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-callback {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
width: 360px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border: 3px solid #e4e7ee;
|
||||||
|
border-top-color: #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cf1322;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5a6072;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -196,17 +196,17 @@ const performLoginRequest = async () => {
|
|||||||
|
|
||||||
if (res && res.code === 200) {
|
if (res && res.code === 200) {
|
||||||
// 登录成功:处理"记住我"
|
// 登录成功:处理"记住我"
|
||||||
|
// 安全:只记住账号与租户名,绝不明文存储密码
|
||||||
if (rememberMe.value) {
|
if (rememberMe.value) {
|
||||||
localStorage.setItem("loginAccount", account.value);
|
localStorage.setItem("loginAccount", account.value);
|
||||||
localStorage.setItem("loginTenantName", tenant_name.value);
|
localStorage.setItem("loginTenantName", tenant_name.value);
|
||||||
localStorage.setItem("loginPassword", password.value);
|
|
||||||
localStorage.setItem("loginRememberMe", "true");
|
localStorage.setItem("loginRememberMe", "true");
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("loginAccount");
|
localStorage.removeItem("loginAccount");
|
||||||
localStorage.removeItem("loginTenantName");
|
localStorage.removeItem("loginTenantName");
|
||||||
localStorage.removeItem("loginPassword");
|
|
||||||
localStorage.setItem("loginRememberMe", "false");
|
localStorage.setItem("loginRememberMe", "false");
|
||||||
}
|
}
|
||||||
|
localStorage.removeItem("loginPassword");
|
||||||
|
|
||||||
authStore.setLoginInfo({ ...res.data, tenant_name: tenant_name.value });
|
authStore.setLoginInfo({ ...res.data, tenant_name: tenant_name.value });
|
||||||
|
|
||||||
@@ -327,17 +327,17 @@ const startGeetest4 = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (loginRes && loginRes.code === 200) {
|
if (loginRes && loginRes.code === 200) {
|
||||||
|
// 安全:只记住账号与租户名,绝不明文存储密码
|
||||||
if (rememberMe.value) {
|
if (rememberMe.value) {
|
||||||
localStorage.setItem("loginAccount", account.value);
|
localStorage.setItem("loginAccount", account.value);
|
||||||
localStorage.setItem("loginTenantName", tenant_name.value);
|
localStorage.setItem("loginTenantName", tenant_name.value);
|
||||||
localStorage.setItem("loginPassword", password.value);
|
|
||||||
localStorage.setItem("loginRememberMe", "true");
|
localStorage.setItem("loginRememberMe", "true");
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("loginAccount");
|
localStorage.removeItem("loginAccount");
|
||||||
localStorage.removeItem("loginTenantName");
|
localStorage.removeItem("loginTenantName");
|
||||||
localStorage.removeItem("loginPassword");
|
|
||||||
localStorage.setItem("loginRememberMe", "false");
|
localStorage.setItem("loginRememberMe", "false");
|
||||||
}
|
}
|
||||||
|
localStorage.removeItem("loginPassword");
|
||||||
|
|
||||||
authStore.setLoginInfo({ ...loginRes.data, tenant_name: tenant_name.value });
|
authStore.setLoginInfo({ ...loginRes.data, tenant_name: tenant_name.value });
|
||||||
const { useTabsStore } = await import("@/stores");
|
const { useTabsStore } = await import("@/stores");
|
||||||
@@ -471,7 +471,6 @@ onMounted(async () => {
|
|||||||
if (savedRemember === "true") {
|
if (savedRemember === "true") {
|
||||||
tenant_name.value = localStorage.getItem("loginTenantName") || "";
|
tenant_name.value = localStorage.getItem("loginTenantName") || "";
|
||||||
account.value = localStorage.getItem("loginAccount") || "";
|
account.value = localStorage.getItem("loginAccount") || "";
|
||||||
password.value = localStorage.getItem("loginPassword") || "";
|
|
||||||
rememberMe.value = true;
|
rememberMe.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1006
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
|||||||
|
# 平台更新功能 - 问题修复记录
|
||||||
|
|
||||||
|
## ❌ 原始错误
|
||||||
|
|
||||||
|
### 前端错误信息
|
||||||
|
```
|
||||||
|
获取数据失败:SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误原因分析
|
||||||
|
|
||||||
|
1. **路径不匹配**:
|
||||||
|
- 前端请求:`/api/platform/upgrade/list`
|
||||||
|
- Vite 代理配置:只代理 `/platform/*` 和 `/backend/*` 路径
|
||||||
|
- 后端路由:注册在 `/api/platform/upgrade/list`
|
||||||
|
|
||||||
|
2. **请求被重定向到 404 页面**:
|
||||||
|
- 由于 `/api/*` 没有被代理,请求直接访问前端开发服务器(端口 4000)
|
||||||
|
- 找不到对应路由,返回 HTML 格式的 404 页面
|
||||||
|
- 前端尝试解析 HTML 为 JSON,导致语法错误
|
||||||
|
|
||||||
|
## ✅ 解决方案
|
||||||
|
|
||||||
|
### 修改前端请求路径
|
||||||
|
|
||||||
|
**文件**: `platform/src/views/platform/platformupgrade/index.vue`
|
||||||
|
|
||||||
|
**修改前**:
|
||||||
|
```javascript
|
||||||
|
const response = await fetch(`/api/platform/upgrade/list?limit=${pageSize.value * 2}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改后**:
|
||||||
|
```javascript
|
||||||
|
const response = await fetch(`/platform/api/upgrade/list?limit=${pageSize.value * 2}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 路径映射说明
|
||||||
|
|
||||||
|
```
|
||||||
|
前端请求:/platform/api/upgrade/list
|
||||||
|
↓
|
||||||
|
Vite 代理 (vite.config.js)
|
||||||
|
↓
|
||||||
|
转发到:http://127.0.0.1:9000/platform/api/upgrade/list
|
||||||
|
↓
|
||||||
|
Beego 路由匹配
|
||||||
|
↓
|
||||||
|
实际路由:/api/platform/upgrade/list ← 注意路径顺序不同!
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 需要调整的地方
|
||||||
|
|
||||||
|
当前方案存在路径不一致的问题。为了保持路径统一,有两种方案:
|
||||||
|
|
||||||
|
### 方案一:修改后端路由(推荐)
|
||||||
|
|
||||||
|
**文件**: `go/routers/api/api.go`
|
||||||
|
|
||||||
|
将路由从 `/api/platform/upgrade/list` 改为 `/platform/api/upgrade/list`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 修改前
|
||||||
|
beego.Router("/api/platform/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List")
|
||||||
|
beego.Router("/api/platform/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail")
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
beego.Router("/platform/api/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List")
|
||||||
|
beego.Router("/platform/api/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail")
|
||||||
|
```
|
||||||
|
|
||||||
|
**优点**:
|
||||||
|
- 前后端路径完全一致
|
||||||
|
- 便于维护和理解
|
||||||
|
|
||||||
|
### 方案二:保持现有路由,前端使用完整路径
|
||||||
|
|
||||||
|
如果后端服务已经部署且无法修改路由,前端可以这样写:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 生产环境:直接使用完整 URL
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:9000';
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/platform/upgrade/list?limit=${pageSize.value * 2}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**缺点**:
|
||||||
|
- 开发环境和生产环境配置复杂
|
||||||
|
- 不利于本地调试
|
||||||
|
|
||||||
|
## 📝 后续建议
|
||||||
|
|
||||||
|
1. **统一路径规范**:
|
||||||
|
- 建议采用方案一,统一使用 `/platform/api/*` 路径结构
|
||||||
|
- 所有 Platform 相关接口都以此前缀开头
|
||||||
|
|
||||||
|
2. **添加错误处理**:
|
||||||
|
```javascript
|
||||||
|
const response = await fetch(`/platform/api/upgrade/list?limit=${pageSize.value * 2}`);
|
||||||
|
|
||||||
|
// 检查响应状态
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是 JSON 格式
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType?.includes('application/json')) {
|
||||||
|
const text = await response.text();
|
||||||
|
console.error('非 JSON 响应:', text.substring(0, 200));
|
||||||
|
throw new Error('API 返回非 JSON 格式');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **环境变量配置**:
|
||||||
|
创建 `.env.development` 和 `.env.production` 文件:
|
||||||
|
```env
|
||||||
|
# .env.development
|
||||||
|
VITE_API_BASE_URL=http://127.0.0.1:9000
|
||||||
|
|
||||||
|
# .env.production
|
||||||
|
VITE_API_BASE_URL=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 立即测试步骤
|
||||||
|
|
||||||
|
1. **确保数据库已初始化**:
|
||||||
|
```sql
|
||||||
|
-- 执行 SQL 脚本
|
||||||
|
source docs/sql/platform_upgrade.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **重启后端服务**:
|
||||||
|
```bash
|
||||||
|
cd go
|
||||||
|
bee run
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启动前端服务**:
|
||||||
|
```bash
|
||||||
|
cd platform
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **访问管理页面**:
|
||||||
|
- 登录系统
|
||||||
|
- 访问:`http://127.0.0.1:4000/platform/platformupgrade`
|
||||||
|
- 应该能看到示例数据
|
||||||
|
|
||||||
|
5. **查看官网首页展示**:
|
||||||
|
- 访问官网首页
|
||||||
|
- 滚动到"近期平台更新内容"区域
|
||||||
|
- 应该能看到 3 条示例更新记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**修复时间**: 2026-09-18
|
||||||
|
**修复内容**: 修正前端 API 请求路径
|
||||||
|
**影响范围**: Platform 管理平台页面
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# 平台更新内容功能说明
|
||||||
|
|
||||||
|
## 📋 功能概述
|
||||||
|
|
||||||
|
该平台更新内容功能用于在官网首页展示平台的近期更新记录,包括更新日期、标题和内容详情。
|
||||||
|
|
||||||
|
## 🗂️ 文件清单
|
||||||
|
|
||||||
|
### 后端文件
|
||||||
|
1. **模型**: `go/models/platform_upgrade.go`
|
||||||
|
- 定义平台更新记录的数据结构
|
||||||
|
|
||||||
|
2. **控制器**: `go/controllers/api_platform_upgrade.go`
|
||||||
|
- `/api/platform/upgrade/list` - 获取更新列表
|
||||||
|
- `/api/platform/upgrade/detail` - 获取单条详情
|
||||||
|
|
||||||
|
3. **路由**: `go/routers/api/api.go`
|
||||||
|
- 已注册 API 路由
|
||||||
|
|
||||||
|
### 前端文件
|
||||||
|
1. **官网首页组件**: `website/src/views/home/components/ScheduleSection.vue`
|
||||||
|
- 展示平台更新时间轴
|
||||||
|
- 自动从后端 API 获取数据
|
||||||
|
|
||||||
|
2. **管理平台页面**: `platform/src/views/platform/platformupgrade/index.vue`
|
||||||
|
- 更新内容的增删改查管理界面
|
||||||
|
|
||||||
|
### 数据库
|
||||||
|
1. **SQL 脚本**: `docs/sql/platform_upgrade.sql`
|
||||||
|
- 建表语句
|
||||||
|
- 示例数据
|
||||||
|
|
||||||
|
## 🚀 使用步骤
|
||||||
|
|
||||||
|
### 1. 执行 SQL 脚本
|
||||||
|
|
||||||
|
在数据库中执行以下 SQL 文件创建表结构:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 路径:docs/sql/platform_upgrade.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本会:
|
||||||
|
- 创建 `yz_platform_upgrade` 表
|
||||||
|
- 插入 3 条示例数据(可选)
|
||||||
|
|
||||||
|
**表结构说明**:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `yz_platform_upgrade` (
|
||||||
|
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`tenant_id` varchar(64) NOT NULL DEFAULT '', -- 租户 ID(空表示全局)
|
||||||
|
`update_date` varchar(20) NOT NULL DEFAULT '', -- 更新日期 YYYY-MM-DD
|
||||||
|
`title` varchar(255) NOT NULL DEFAULT '', -- 更新标题
|
||||||
|
`content` text, -- 更新内容(支持 HTML)
|
||||||
|
`sort` int(11) NOT NULL DEFAULT '0', -- 排序权重(越大越靠前)
|
||||||
|
`status` tinyint(4) NOT NULL DEFAULT '1', -- 状态:0-隐藏 1-显示
|
||||||
|
`create_user_id` varchar(64) NOT NULL DEFAULT '', -- 创建人 ID
|
||||||
|
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` datetime DEFAULT NULL,
|
||||||
|
`delete_time` datetime DEFAULT NULL, -- 软删除时间
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 重启后端服务
|
||||||
|
|
||||||
|
确保后端服务重新加载,注册新的路由:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go
|
||||||
|
# 如果是开发环境
|
||||||
|
bee run
|
||||||
|
|
||||||
|
# 如果是生产环境
|
||||||
|
./main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置菜单权限(可选)
|
||||||
|
|
||||||
|
如需在后台管理系统中访问"更新管理"页面,需要配置菜单权限:
|
||||||
|
|
||||||
|
**菜单配置**:
|
||||||
|
- 菜单名称:更新管理
|
||||||
|
- 路由地址:/platform/platformupgrade
|
||||||
|
- 组件路径:platform/platformupgrade
|
||||||
|
- 是否显示:是
|
||||||
|
- 排序:根据实际需要设置
|
||||||
|
|
||||||
|
### 4. 测试功能
|
||||||
|
|
||||||
|
#### 4.1 查看官网首页展示
|
||||||
|
|
||||||
|
访问官网首页,滚动到"近期平台更新内容"区域,应该能看到从数据库读取的更新记录。
|
||||||
|
|
||||||
|
#### 4.2 后台管理
|
||||||
|
|
||||||
|
访问 `http://localhost:端口/platform/platformupgrade`(具体端口根据实际情况),可以:
|
||||||
|
- 新增更新内容
|
||||||
|
- 编辑已有内容
|
||||||
|
- 删除更新记录
|
||||||
|
- 调整排序和显示状态
|
||||||
|
|
||||||
|
#### 4.3 API 接口测试
|
||||||
|
|
||||||
|
**获取更新列表**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:端口/api/platform/upgrade/list?limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
**获取单条详情**:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:端口/api/platform/upgrade/detail?id=1
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"update_date": "2026-09-18",
|
||||||
|
"title": "平台 V3.0 版本发布",
|
||||||
|
"content": "<h3>🎉 核心功能更新</h3>...",
|
||||||
|
"create_time": "2026-09-18 10:00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 数据维护建议
|
||||||
|
|
||||||
|
### 添加新更新记录
|
||||||
|
|
||||||
|
1. 通过后台管理界面点击"新增更新内容"
|
||||||
|
2. 填写以下信息:
|
||||||
|
- **更新日期**: 选择发布日期
|
||||||
|
- **标题**: 简明扼要地描述更新主题
|
||||||
|
- **更新内容**: 详细描述更新内容,支持 HTML 格式
|
||||||
|
- **排序**: 数值越大越靠前显示
|
||||||
|
- **状态**: 选择显示或隐藏
|
||||||
|
|
||||||
|
### 更新内容格式建议
|
||||||
|
|
||||||
|
推荐使用 HTML 格式,示例:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h3>🎉 核心功能更新</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>CRM 模块增强</strong>: 新增上下游项目金额拆分管理</li>
|
||||||
|
<li><strong>回款精细化管理</strong>: 支持多种回款周期类型</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>💡 体验优化</h3>
|
||||||
|
<ul>
|
||||||
|
<li>移动端全面适配</li>
|
||||||
|
<li>性能优化,加载速度提升 50%</li>
|
||||||
|
</ul>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 删除记录
|
||||||
|
|
||||||
|
建议使用"隐藏"状态而非直接删除,保留历史记录便于追溯。如需彻底删除,请在后台管理中操作。
|
||||||
|
|
||||||
|
## 🔧 常见问题
|
||||||
|
|
||||||
|
### Q1: 官网首页没有显示更新内容?
|
||||||
|
|
||||||
|
**检查项**:
|
||||||
|
1. 确认数据库表已创建且包含数据
|
||||||
|
2. 确认 `status = 1`(显示状态)
|
||||||
|
3. 检查浏览器控制台是否有 API 请求错误
|
||||||
|
4. 确认后端服务已重启并正确注册路由
|
||||||
|
|
||||||
|
### Q2: 如何自定义显示数量?
|
||||||
|
|
||||||
|
修改 `ScheduleSection.vue` 中的 API 调用参数:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const response = await fetch('/api/platform/upgrade/list?limit=20');
|
||||||
|
```
|
||||||
|
|
||||||
|
或在后端控制器的 `List()` 方法中调整默认值。
|
||||||
|
|
||||||
|
### Q3: 能否按租户隔离显示?
|
||||||
|
|
||||||
|
当前设计为全局显示(`tenant_id` 为空)。如需按租户隔离,可:
|
||||||
|
1. 在查询时增加租户过滤条件
|
||||||
|
2. 在前端传递租户 ID 参数
|
||||||
|
3. 修改控制器逻辑
|
||||||
|
|
||||||
|
## 📊 扩展功能建议
|
||||||
|
|
||||||
|
1. **版本对比**: 增加版本号字段,支持版本对比
|
||||||
|
2. **附件下载**: 支持上传更新相关的附件(如安装包)
|
||||||
|
3. **分类标签**: 增加功能分类(如 CRM/OA/ERP/CMS)
|
||||||
|
4. **统计功能**: 记录每条更新的阅读量、点赞数等
|
||||||
|
5. **推送通知**: 更新时通过邮件/短信通知用户
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
如有问题,请联系开发团队或查看项目文档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最后更新**: 2026-09-18
|
||||||
+217
@@ -0,0 +1,217 @@
|
|||||||
|
# 平台更新功能开发总结
|
||||||
|
|
||||||
|
## ✅ 已完成的工作
|
||||||
|
|
||||||
|
### 1. 前端修改
|
||||||
|
|
||||||
|
#### 官网首页组件 (ScheduleSection.vue)
|
||||||
|
- ✅ 将"近期活动与发布节奏"改为"近期平台更新内容"
|
||||||
|
- ✅ 数据结构从硬编码改为从 API 动态获取
|
||||||
|
- ✅ 字段映射:`update_date`、`title`、`content`
|
||||||
|
- ✅ 添加加载状态和空数据提示
|
||||||
|
- ✅ 管理入口链接指向 `/platform/platformupgrade`
|
||||||
|
|
||||||
|
**关键改动**:
|
||||||
|
```vue
|
||||||
|
// 原数据结构
|
||||||
|
interface ScheduleItem {
|
||||||
|
range: string;
|
||||||
|
period: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新数据结构
|
||||||
|
interface UpgradeItem {
|
||||||
|
id: number;
|
||||||
|
update_date: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
create_time: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 后端开发
|
||||||
|
|
||||||
|
#### 数据模型 (platform_upgrade.go)
|
||||||
|
```go
|
||||||
|
type PlatformUpgrade struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto"`
|
||||||
|
TenantID string `orm:"column(tenant_id);size(64)"`
|
||||||
|
UpdateDate string `orm:"column(update_date);size(20)"`
|
||||||
|
Title string `orm:"column(title);size(255)"`
|
||||||
|
Content string `orm:"column(content);type(text)"`
|
||||||
|
Sort int `orm:"column(sort);default(0)"`
|
||||||
|
Status int8 `orm:"column(status);default(1)"`
|
||||||
|
CreateUserID string `orm:"column(create_user_id);size(64)"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);auto_now"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);null"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### API 控制器 (api_platform_upgrade.go)
|
||||||
|
- **List()**: GET `/api/platform/upgrade/list`
|
||||||
|
- 支持 `limit` 参数(默认 10,最大 50)
|
||||||
|
- 按 `sort` 降序、`create_time` 降序排列
|
||||||
|
- 只返回 `status=1` 的记录
|
||||||
|
|
||||||
|
- **Detail()**: GET `/api/platform/upgrade/detail?id={id}`
|
||||||
|
- 获取单条更新详情
|
||||||
|
- 权限验证和参数校验
|
||||||
|
|
||||||
|
#### 路由注册 (api.go)
|
||||||
|
```go
|
||||||
|
beego.Router("/api/platform/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List")
|
||||||
|
beego.Router("/api/platform/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 管理平台页面 (platformupgrade/index.vue)
|
||||||
|
|
||||||
|
完整的管理界面,包含:
|
||||||
|
- ✅ 数据表格展示
|
||||||
|
- ✅ 新增/编辑对话框
|
||||||
|
- ✅ 删除确认
|
||||||
|
- ✅ 表单验证
|
||||||
|
- ✅ 排序和状态管理
|
||||||
|
|
||||||
|
**功能特性**:
|
||||||
|
- 日期选择器
|
||||||
|
- HTML 内容输入
|
||||||
|
- 数字输入(排序)
|
||||||
|
- 状态切换(显示/隐藏)
|
||||||
|
|
||||||
|
### 4. 数据库脚本 (platform_upgrade.sql)
|
||||||
|
|
||||||
|
提供完整的建表 SQL 和示例数据:
|
||||||
|
- ✅ 表结构定义
|
||||||
|
- ✅ 索引优化
|
||||||
|
- ✅ 3 条示例数据(V3.0 版本发布、OA 升级、CRM 优化)
|
||||||
|
- ✅ UTF8MB4 字符集支持
|
||||||
|
|
||||||
|
### 5. 文档
|
||||||
|
|
||||||
|
- ✅ 《平台更新功能说明.md》- 详细使用文档
|
||||||
|
- ✅ 《开发总结.md》- 本文档
|
||||||
|
|
||||||
|
## 📁 文件清单
|
||||||
|
|
||||||
|
```
|
||||||
|
e:\Demo\Go\yunzerwebsiteallinone\
|
||||||
|
├── go/
|
||||||
|
│ ├── models/
|
||||||
|
│ │ └── platform_upgrade.go # 新增:数据模型
|
||||||
|
│ ├── controllers/
|
||||||
|
│ │ └── api_platform_upgrade.go # 新增:API 控制器
|
||||||
|
│ └── routers/
|
||||||
|
│ └── api/
|
||||||
|
│ └── api.go # 修改:注册新路由
|
||||||
|
│
|
||||||
|
├── website/
|
||||||
|
│ └── src/views/home/components/
|
||||||
|
│ └── ScheduleSection.vue # 修改:官网首页组件
|
||||||
|
│
|
||||||
|
├── platform/
|
||||||
|
│ └── src/views/platform/
|
||||||
|
│ └── platformupgrade/
|
||||||
|
│ └── index.vue # 新增:管理页面
|
||||||
|
│
|
||||||
|
└── docs/
|
||||||
|
├── sql/
|
||||||
|
│ └── platform_upgrade.sql # 新增:SQL 脚本
|
||||||
|
└── 平台更新功能说明.md # 新增:使用说明
|
||||||
|
└── 开发总结.md # 新增:本文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 部署步骤
|
||||||
|
|
||||||
|
### Step 1: 执行 SQL 脚本
|
||||||
|
```bash
|
||||||
|
# 在 MySQL 中执行
|
||||||
|
source e:\Demo\Go\yunzerwebsiteallinone\docs\sql\platform_upgrade.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 重启后端服务
|
||||||
|
```bash
|
||||||
|
cd e:\Demo\Go\yunzerwebsiteallinone\go
|
||||||
|
# 重新编译运行
|
||||||
|
bee run
|
||||||
|
# 或
|
||||||
|
go build -o main.exe
|
||||||
|
./main.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: 测试功能
|
||||||
|
1. 访问官网首页,查看更新内容展示
|
||||||
|
2. 访问后台管理页面,进行增删改查操作
|
||||||
|
3. 调用 API 接口验证数据
|
||||||
|
|
||||||
|
## 🔍 技术要点
|
||||||
|
|
||||||
|
### 1. 前后端分离架构
|
||||||
|
- 前端通过 AJAX 请求获取数据
|
||||||
|
- 后端提供 RESTful API
|
||||||
|
- 数据格式统一为 JSON
|
||||||
|
|
||||||
|
### 2. 软删除机制
|
||||||
|
- 使用 `delete_time` 字段实现软删除
|
||||||
|
- 查询时过滤 `delete_time__isnull`
|
||||||
|
- 保留历史记录便于追溯
|
||||||
|
|
||||||
|
### 3. 排序策略
|
||||||
|
- `sort` 字段控制显示顺序
|
||||||
|
- 数值越大越靠前
|
||||||
|
- 支持管理员灵活调整
|
||||||
|
|
||||||
|
### 4. 租户隔离设计
|
||||||
|
- `tenant_id` 字段预留租户标识
|
||||||
|
- 当前实现为全局显示(空字符串)
|
||||||
|
- 未来可扩展为按租户隔离
|
||||||
|
|
||||||
|
### 5. HTML 内容支持
|
||||||
|
- `content` 字段使用 TEXT 类型
|
||||||
|
- 支持富文本 HTML 格式
|
||||||
|
- 前端可渲染格式化内容
|
||||||
|
|
||||||
|
## 📊 数据流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[管理员] -->|增删改查 | B(后台管理页面)
|
||||||
|
B -->|CRUD 操作 | C[后端 API]
|
||||||
|
C -->|读写 | D[(MySQL 数据库)]
|
||||||
|
E[官网首页] -->|GET /api/platform/upgrade/list| C
|
||||||
|
C -->|返回数据 | E
|
||||||
|
E -->|展示 | F[访客]
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **数据库连接**: 确保数据库连接正常
|
||||||
|
2. **路由注册**: 后端必须重启才能生效
|
||||||
|
3. **权限配置**: 如需后台管理,需配置菜单权限
|
||||||
|
4. **数据安全**: 建议对 HTML 内容进行 XSS 过滤
|
||||||
|
5. **性能优化**: 大数据量时需考虑分页和缓存
|
||||||
|
|
||||||
|
## 🔄 后续优化建议
|
||||||
|
|
||||||
|
1. **分页功能**: 当前列表不分页,数据量大时需优化
|
||||||
|
2. **富文本编辑器**: 管理页面可使用 Quill/CKEditor 等编辑器
|
||||||
|
3. **版本对比**: 增加版本号字段,支持版本对比功能
|
||||||
|
4. **附件上传**: 支持上传更新相关的安装包、文档等
|
||||||
|
5. **分类标签**: 增加功能分类(CRM/OA/ERP/CMS)
|
||||||
|
6. **统计功能**: 记录阅读量、点赞数等互动数据
|
||||||
|
7. **推送通知**: 更新时自动通知用户
|
||||||
|
|
||||||
|
## 📞 联系方式
|
||||||
|
|
||||||
|
如有问题,请查阅:
|
||||||
|
- 《平台更新功能说明.md》
|
||||||
|
- 项目 README
|
||||||
|
- 相关代码注释
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**开发完成时间**: 2026-09-18
|
||||||
|
**开发者**: AI Assistant
|
||||||
|
**版本**: v1.0.0
|
||||||
@@ -53,6 +53,30 @@ acme_directory =
|
|||||||
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
|
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
|
||||||
ssl_cert_dir = /www/wwwroot/ssl-certs
|
ssl_cert_dir = /www/wwwroot/ssl-certs
|
||||||
|
|
||||||
|
# ==================== 统一认证中心(UAC)基础配置 ====================
|
||||||
|
# 全局 JWT 鉴权模式:off=不启用(等同改造前)/ warn=观察模式只记日志不拦截 / on=真正拦截401
|
||||||
|
# 上线步骤:先 warn 跑一段时间核对日志,确认白名单无遗漏后再改 on
|
||||||
|
auth_enforce = warn
|
||||||
|
# 额外免鉴权路径前缀(在内置白名单之外追加),多个用英文逗号分隔
|
||||||
|
auth_whitelist =
|
||||||
|
|
||||||
|
# JWT 签发者(统一认证中心地址)
|
||||||
|
jwt_issuer = https://api.yunzer.cn/auth
|
||||||
|
# HS256 主密钥:留空则沿用内置默认密钥(兼容历史 token),生产环境务必配置
|
||||||
|
# 注意:修改后所有已签发的旧 token 立即失效,需在业务低峰期操作
|
||||||
|
jwt_secret = peaceandlove
|
||||||
|
# HS256 轮换密钥(用于平滑换密钥),格式:kid1:secret1,kid2:secret2
|
||||||
|
jwt_secrets =
|
||||||
|
|
||||||
|
# RS256 密钥对(P1 认证中心启用;未配置时自动回落 HS256,不影响启动)
|
||||||
|
# 生成命令:
|
||||||
|
# openssl genpkey -algorithm RSA -out jwt_rsa_private.pem -pkeyopt rsa_keygen_bits:2048
|
||||||
|
# openssl rsa -pubout -in jwt_rsa_private.pem -out jwt_rsa_public.pem
|
||||||
|
# 私钥文件不要提交到代码仓库
|
||||||
|
jwt_rsa_private_key_file = E:\Demo\ssh\jwt_rsa_private.pem
|
||||||
|
jwt_rsa_public_key_file = E:\Demo\ssh\jwt_rsa_public.pem
|
||||||
|
jwt_rsa_kid = rsa-1
|
||||||
|
|
||||||
# ==================== 微信公众号(服务号) ====================
|
# ==================== 微信公众号(服务号) ====================
|
||||||
# 服务号 AppSecret / EncodingAESKey 入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
|
# 服务号 AppSecret / EncodingAESKey 入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
|
||||||
# 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。
|
# 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApiPlatformUpgradeController 平台更新内容接口
|
||||||
|
type ApiPlatformUpgradeController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// List GET /api/platform/upgrade/list 获取平台更新列表(公开接口)
|
||||||
|
func (c *ApiPlatformUpgradeController) List() {
|
||||||
|
// 可选参数:limit - 返回数量限制,默认 10
|
||||||
|
limitStr := c.GetString("limit")
|
||||||
|
limit := 10
|
||||||
|
if limitStr != "" {
|
||||||
|
val, err := strconv.Atoi(limitStr)
|
||||||
|
if err == nil {
|
||||||
|
limit = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 50 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询显示状态的更新记录,按排序字段和创建时间倒序
|
||||||
|
var upgradeList []models.PlatformUpgradeNotice
|
||||||
|
qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)).
|
||||||
|
Filter("status", 1).
|
||||||
|
Filter("delete_time__isnull", true)
|
||||||
|
|
||||||
|
_, err := qs.OrderBy("-sort", "-create_time").Limit(limit).All(&upgradeList)
|
||||||
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 500,
|
||||||
|
"msg": "查询失败",
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为前端需要的格式
|
||||||
|
data := make([]map[string]interface{}, 0, len(upgradeList))
|
||||||
|
for _, item := range upgradeList {
|
||||||
|
record := map[string]interface{}{
|
||||||
|
"id": item.ID,
|
||||||
|
"update_date": item.UpdateDate,
|
||||||
|
"title": item.Title,
|
||||||
|
"content": item.Content,
|
||||||
|
"create_time": item.CreateTime.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
data = append(data, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail GET /api/platform/upgrade/detail?id={id} 获取单条更新详情
|
||||||
|
func (c *ApiPlatformUpgradeController) Detail() {
|
||||||
|
idStr := strings.TrimSpace(c.GetString("id"))
|
||||||
|
if idStr == "" {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "缺少参数 id",
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var id uint64
|
||||||
|
var parseErr error
|
||||||
|
id, parseErr = strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if parseErr != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 400,
|
||||||
|
"msg": "参数错误",
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.PlatformUpgradeNotice
|
||||||
|
var queryErr error
|
||||||
|
queryErr = models.Orm.QueryTable(new(models.PlatformUpgradeNotice)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("status", 1).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&row)
|
||||||
|
if queryErr != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "更新内容不存在",
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"id": row.ID,
|
||||||
|
"tenant_id": row.TenantID,
|
||||||
|
"update_date": row.UpdateDate,
|
||||||
|
"title": row.Title,
|
||||||
|
"content": row.Content,
|
||||||
|
"sort": row.Sort,
|
||||||
|
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
authsvc "server/services/auth"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
"github.com/beego/beego/v2/server/web/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthLoginController 认证中心登录相关端点
|
||||||
|
type AuthLoginController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthLoginController) serveJSON(data map[string]interface{}) {
|
||||||
|
c.Data["json"] = data
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginPage 统一登录页(beego 模板渲染)
|
||||||
|
// GET /auth/login?redirect=<base64(回跳URL)>&client_id=xxx&step=tenant
|
||||||
|
func (c *AuthLoginController) LoginPage() {
|
||||||
|
clientID := strings.TrimSpace(c.GetString("client_id"))
|
||||||
|
redirect := c.GetString("redirect")
|
||||||
|
step := c.GetString("step")
|
||||||
|
errMsg := c.GetString("error")
|
||||||
|
|
||||||
|
// 已登录且已选企业:直接放行到 authorize,无需再输密码
|
||||||
|
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||||
|
if sid != "" && step != "tenant" {
|
||||||
|
if session, err := authsvc.GetSession(sid); err == nil && session.Tid != authsvc.PendingTenantID {
|
||||||
|
if back := decodeRedirect(redirect); back != "" {
|
||||||
|
c.Redirect(back, 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业选择步骤:需要展示可进入的企业列表
|
||||||
|
tenants := make([]authsvc.TenantOption, 0)
|
||||||
|
if sid != "" {
|
||||||
|
if session, err := authsvc.GetSession(sid); err == nil {
|
||||||
|
list, _ := authsvc.ListTenantOptions(session.IdentityID)
|
||||||
|
tenants = list
|
||||||
|
// 只有一家企业直接进入,无需展示选择页
|
||||||
|
if len(tenants) == 1 && step == "tenant" {
|
||||||
|
if _, _, err := authsvc.ChooseTenant(sid, tenants[0].Tid, clientID); err == nil {
|
||||||
|
if back := decodeRedirect(redirect); back != "" {
|
||||||
|
c.Redirect(back, 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data["ClientID"] = clientID
|
||||||
|
c.Data["Redirect"] = redirect
|
||||||
|
c.Data["Step"] = step
|
||||||
|
c.Data["Error"] = errMsg
|
||||||
|
c.Data["Tenants"] = tenants
|
||||||
|
c.TplName = "auth/login.tpl"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginSubmit 提交登录
|
||||||
|
// POST /auth/login { account, password, client_id, redirect }
|
||||||
|
func (c *AuthLoginController) LoginSubmit() {
|
||||||
|
var req struct {
|
||||||
|
Account string `json:"account"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Redirect string `json:"redirect"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
body := c.Ctx.Input.RequestBody
|
||||||
|
if len(body) == 0 {
|
||||||
|
var err error
|
||||||
|
body, err = io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil || len(body) == 0 {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Account) == "" || req.Password == "" {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 400, "msg": "请输入账号和密码"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIP := c.Ctx.Input.IP()
|
||||||
|
userAgent := c.Ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
result, err := authsvc.PasswordLogin(req.Account, req.Password, req.ClientID, clientIP, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入认证中心会话 Cookie,供后续 authorize 识别登录态
|
||||||
|
setSessionCookieForCtx(c.Ctx, result.Tokens.Sid)
|
||||||
|
|
||||||
|
if result.NeedChooseTenant {
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "请选择要进入的企业",
|
||||||
|
"need_choose_tenant": true,
|
||||||
|
"tenants": result.Tenants,
|
||||||
|
"sid": result.Tokens.Sid,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
back := decodeRedirect(req.Redirect)
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "登录成功",
|
||||||
|
"redirect": back,
|
||||||
|
"tokens": result.Tokens,
|
||||||
|
"tenants": result.Tenants,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenants 当前登录身份可进入的企业列表(Bearer Token)
|
||||||
|
// GET /auth/tenants
|
||||||
|
func (c *AuthLoginController) Tenants() {
|
||||||
|
claims := claimsFromHeaderLogin(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.Ctx.Output.SetStatus(401)
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := authsvc.ListTenantOptions(uint64(claims.UserID))
|
||||||
|
if err != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchTenant 免密切换企业
|
||||||
|
// POST /auth/switch-tenant { tid, client_id, redirect }
|
||||||
|
func (c *AuthLoginController) SwitchTenant() {
|
||||||
|
var req struct {
|
||||||
|
Tid uint64 `json:"tid"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Redirect string `json:"redirect"`
|
||||||
|
}
|
||||||
|
body := c.Ctx.Input.RequestBody
|
||||||
|
if len(body) == 0 {
|
||||||
|
body, _ = io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &req)
|
||||||
|
|
||||||
|
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||||
|
if sid == "" {
|
||||||
|
if claims := claimsFromHeaderLogin(c); claims != nil {
|
||||||
|
sid = claims.Sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sid == "" || req.Tid == 0 {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, profile, err := authsvc.ChooseTenant(sid, req.Tid, req.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 403, "msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookieForCtx(c.Ctx, tokens.Sid)
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "切换成功",
|
||||||
|
"redirect": decodeRedirect(req.Redirect),
|
||||||
|
"data": map[string]interface{}{"tokens": tokens, "identity": profile},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutPage 单点登出入口(OIDC end_session_endpoint)
|
||||||
|
// GET /auth/logout?post_logout_redirect_uri=&client_id=
|
||||||
|
//
|
||||||
|
// 清除认证中心 Cookie 并吊销会话与令牌;回跳地址必须在应用白名单内。
|
||||||
|
// 登记了 backchannel_logout_uri 的应用会收到服务端登出通知(P2 阶段接入)。
|
||||||
|
func (c *AuthLoginController) LogoutPage() {
|
||||||
|
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||||
|
access := bearerTokenLogin(c)
|
||||||
|
if sid != "" {
|
||||||
|
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
|
||||||
|
}
|
||||||
|
if access != "" {
|
||||||
|
_ = authsvc.RevokeTokenPair("", access, models.RevokeReasonLogout)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
c.Redirect(back, 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions 在线设备列表
|
||||||
|
// GET /auth/sessions
|
||||||
|
func (c *AuthLoginController) Sessions() {
|
||||||
|
claims := claimsFromHeaderLogin(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.Ctx.Output.SetStatus(401)
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := authsvc.ListActiveSessions(uint64(claims.UserID))
|
||||||
|
if err != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// KickSession 踢下线指定设备
|
||||||
|
// POST /auth/sessions/kick { sid }
|
||||||
|
func (c *AuthLoginController) KickSession() {
|
||||||
|
var req struct {
|
||||||
|
Sid string `json:"sid"`
|
||||||
|
}
|
||||||
|
body := c.Ctx.Input.RequestBody
|
||||||
|
if len(body) == 0 {
|
||||||
|
body, _ = io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &req)
|
||||||
|
|
||||||
|
claims := claimsFromHeaderLogin(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.Ctx.Output.SetStatus(401)
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Sid == "" {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 只允许踢自己名下的会话
|
||||||
|
owned := false
|
||||||
|
if list, err := authsvc.ListActiveSessions(uint64(claims.UserID)); err == nil {
|
||||||
|
for _, s := range list {
|
||||||
|
if s.Sid == req.Sid {
|
||||||
|
owned = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权操作"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := authsvc.RevokeSession(req.Sid, models.RevokeReasonAdmin); err != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 500, "msg": "操作失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已下线"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutAction 退出登录(清 Cookie + 吊销会话与令牌)
|
||||||
|
// POST /auth/logout
|
||||||
|
func (c *AuthLoginController) LogoutAction() {
|
||||||
|
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||||
|
access := ""
|
||||||
|
if claims := claimsFromHeaderLogin(c); claims != nil {
|
||||||
|
if sid == "" {
|
||||||
|
sid = claims.Sid
|
||||||
|
}
|
||||||
|
access = bearerTokenLogin(c)
|
||||||
|
}
|
||||||
|
_ = authsvc.Logout(access, "")
|
||||||
|
if sid != "" {
|
||||||
|
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
|
||||||
|
}
|
||||||
|
clearSessionCookieForCtx(c.Ctx)
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyConfig 租户登录验证配置(替代 /backend/login/getOpenVerify)
|
||||||
|
// GET /auth/verify-config?tid=
|
||||||
|
func (c *AuthLoginController) VerifyConfig() {
|
||||||
|
tid, _ := c.GetInt64("tid", 0)
|
||||||
|
cfg := authsvc.GetTenantSessionPolicy(uint64(tid))
|
||||||
|
|
||||||
|
var authCfg models.AuthTenantAuthConfig
|
||||||
|
verifyType := "captcha"
|
||||||
|
openVerify := 1
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
|
||||||
|
Filter("tid", tid).One(&authCfg); err == nil {
|
||||||
|
verifyType = authCfg.VerifyType
|
||||||
|
openVerify = int(authCfg.OpenVerify)
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"openVerify": openVerify,
|
||||||
|
"verifyType": verifyType,
|
||||||
|
"sessionTTL": cfg.SessionTTL,
|
||||||
|
"maxSession": cfg.MaxSession,
|
||||||
|
"mfaRequired": 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 工具
|
||||||
|
|
||||||
|
func decodeRedirect(encoded string) string {
|
||||||
|
if encoded == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
back := string(raw)
|
||||||
|
// 只允许站内地址,防开放重定向
|
||||||
|
if !strings.HasPrefix(back, "/auth/") && !strings.HasPrefix(back, "http") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return back
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimsFromHeaderLogin(c *AuthLoginController) *jwtutil.Claims {
|
||||||
|
token := bearerTokenLogin(c)
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
claims, err := jwtutil.ParseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerTokenLogin(c *AuthLoginController) string {
|
||||||
|
header := c.Ctx.Request.Header.Get("Authorization")
|
||||||
|
if header == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(header, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionCookieForCtx(ctx *context.Context, sid string) {
|
||||||
|
ctx.Output.Header("Set-Cookie",
|
||||||
|
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
|
||||||
|
sessionCookieName, sid, sessionCookieTTL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSessionCookieForCtx(ctx *context.Context) {
|
||||||
|
ctx.Output.Header("Set-Cookie",
|
||||||
|
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
// Package auth 统一认证中心(UAC)控制器:api.yunzer.cn/auth
|
||||||
|
//
|
||||||
|
// 实现 OIDC 1.0(基于 OAuth 2.1 + PKCE)标准端点,
|
||||||
|
// 以后每开发一个新软件,只需在 yz_auth_client 注册一条即可接入。
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authsvc "server/services/auth"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 认证中心会话 Cookie(仅作用于认证中心域名,用于 authorize 阶段识别登录态)
|
||||||
|
const (
|
||||||
|
sessionCookieName = "yz_sid"
|
||||||
|
sessionCookieTTL = 7200
|
||||||
|
)
|
||||||
|
|
||||||
|
// grant_type 常量
|
||||||
|
const (
|
||||||
|
GrantAuthCode = "authorization_code"
|
||||||
|
GrantRefresh = "refresh_token"
|
||||||
|
ResponseTypeCode = "code"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthOidcController OIDC 标准端点
|
||||||
|
type AuthOidcController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthOidcController) serveJSON(data map[string]interface{}) {
|
||||||
|
c.Data["json"] = data
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AuthOidcController) fail(status int, msg string) {
|
||||||
|
c.Ctx.Output.SetStatus(status)
|
||||||
|
c.serveJSON(map[string]interface{}{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discovery OIDC 发现文档
|
||||||
|
// GET /auth/.well-known/openid-configuration
|
||||||
|
func (c *AuthOidcController) Discovery() {
|
||||||
|
issuer := jwtutil.Issuer()
|
||||||
|
if issuer == "" {
|
||||||
|
issuer = fmt.Sprintf("https://%s/auth", c.Ctx.Request.Host)
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"issuer": issuer,
|
||||||
|
"authorization_endpoint": issuer + "/authorize",
|
||||||
|
"token_endpoint": issuer + "/token",
|
||||||
|
"userinfo_endpoint": issuer + "/userinfo",
|
||||||
|
"introspection_endpoint": issuer + "/introspect",
|
||||||
|
"revocation_endpoint": issuer + "/revoke",
|
||||||
|
"end_session_endpoint": issuer + "/logout",
|
||||||
|
"jwks_uri": issuer + "/jwks.json",
|
||||||
|
"response_types_supported": []string{"code"},
|
||||||
|
"grant_types_supported": []string{GrantAuthCode, GrantRefresh},
|
||||||
|
"subject_types_supported": []string{"public"},
|
||||||
|
"id_token_signing_alg_values_supported": []string{jwtutil.AlgRS256, jwtutil.AlgHS256},
|
||||||
|
"code_challenge_methods_supported": []string{"S256"},
|
||||||
|
"scopes_supported": []string{"openid", "profile", "tenant"},
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWKS 公钥集合(各应用本地验签用)
|
||||||
|
// GET /auth/jwks.json
|
||||||
|
func (c *AuthOidcController) JWKS() {
|
||||||
|
keys := jwtutil.JWKS()
|
||||||
|
if keys == nil {
|
||||||
|
keys = []jwtutil.JWK{}
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{"keys": keys}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize 授权端点
|
||||||
|
// GET /auth/authorize?client_id=&redirect_uri=&response_type=code&scope=&state=&code_challenge=&code_challenge_method=S256
|
||||||
|
//
|
||||||
|
// 未登录时重定向到统一登录页,登录后再回到本端点完成授权。
|
||||||
|
func (c *AuthOidcController) Authorize() {
|
||||||
|
clientID := strings.TrimSpace(c.GetString("client_id"))
|
||||||
|
redirectURI := strings.TrimSpace(c.GetString("redirect_uri"))
|
||||||
|
responseType := strings.TrimSpace(c.GetString("response_type"))
|
||||||
|
state := c.GetString("state")
|
||||||
|
scope := c.GetString("scope")
|
||||||
|
if scope == "" {
|
||||||
|
scope = "openid"
|
||||||
|
}
|
||||||
|
challenge := c.GetString("code_challenge")
|
||||||
|
challengeMethod := c.GetString("code_challenge_method")
|
||||||
|
if challengeMethod == "" {
|
||||||
|
challengeMethod = "S256"
|
||||||
|
}
|
||||||
|
nonce := c.GetString("nonce")
|
||||||
|
|
||||||
|
if clientID == "" || redirectURI == "" {
|
||||||
|
c.Ctx.Output.SetStatus(400)
|
||||||
|
_, _ = c.Ctx.ResponseWriter.Write([]byte("缺少 client_id 或 redirect_uri"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if responseType != ResponseTypeCode {
|
||||||
|
c.Ctx.Output.SetStatus(400)
|
||||||
|
_, _ = c.Ctx.ResponseWriter.Write([]byte("仅支持 response_type=code"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := findClient(clientID)
|
||||||
|
if err != nil {
|
||||||
|
c.Ctx.Output.SetStatus(400)
|
||||||
|
_, _ = c.Ctx.ResponseWriter.Write([]byte("client_id 无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowRedirect(client, redirectURI) {
|
||||||
|
c.Ctx.Output.SetStatus(400)
|
||||||
|
_, _ = c.Ctx.ResponseWriter.Write([]byte("redirect_uri 未登记"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录态:Cookie 优先(浏览器跳转),其次 Authorization(服务端调用)
|
||||||
|
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
|
||||||
|
if sid == "" {
|
||||||
|
if claims := claimsFromHeader(c); claims != nil {
|
||||||
|
sid = claims.Sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sid == "" {
|
||||||
|
// 未登录 → 去登录页,登录后带着参数回来
|
||||||
|
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
|
||||||
|
target := fmt.Sprintf("/auth/login?redirect=%s&client_id=%s",
|
||||||
|
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
|
||||||
|
c.Redirect(target, 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := authsvc.GetSession(sid)
|
||||||
|
if err != nil {
|
||||||
|
clearSessionCookie(c)
|
||||||
|
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
|
||||||
|
target := fmt.Sprintf("/auth/login?redirect=%s&client_id=%s",
|
||||||
|
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
|
||||||
|
c.Redirect(target, 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录但未选择企业:跳登录页的企业选择步骤
|
||||||
|
if session.Tid == authsvc.PendingTenantID {
|
||||||
|
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
|
||||||
|
target := fmt.Sprintf("/auth/login?step=tenant&redirect=%s&client_id=%s",
|
||||||
|
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
|
||||||
|
c.Redirect(target, 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := issueAuthCode(client.ClientID, session.IdentityID, session.Tid, redirectURI, challenge, challengeMethod, scope, nonce)
|
||||||
|
if err != nil {
|
||||||
|
c.Ctx.Output.SetStatus(500)
|
||||||
|
_, _ = c.Ctx.ResponseWriter.Write([]byte("签发授权码失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(redirectURI, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
c.Redirect(fmt.Sprintf("%s%scode=%s&state=%s", redirectURI, sep, code, state), 302)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token 令牌端点
|
||||||
|
// POST /auth/token
|
||||||
|
// - grant_type=authorization_code:code + code_verifier(PKCE)+ client_id
|
||||||
|
// - grant_type=refresh_token:refresh_token + client_id
|
||||||
|
func (c *AuthOidcController) Token() {
|
||||||
|
grantType := strings.TrimSpace(c.GetString("grant_type"))
|
||||||
|
clientID := strings.TrimSpace(c.GetString("client_id"))
|
||||||
|
if clientID == "" {
|
||||||
|
// 兼容表单/JSON 以外的取参方式
|
||||||
|
clientID = strings.TrimSpace(c.Ctx.Request.FormValue("client_id"))
|
||||||
|
}
|
||||||
|
if grantType == "" {
|
||||||
|
grantType = strings.TrimSpace(c.Ctx.Request.FormValue("grant_type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch grantType {
|
||||||
|
case GrantAuthCode:
|
||||||
|
code := strings.TrimSpace(c.GetString("code"))
|
||||||
|
verifier := strings.TrimSpace(c.GetString("code_verifier"))
|
||||||
|
if code == "" || verifier == "" || clientID == "" {
|
||||||
|
c.fail(400, "invalid_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pair, claims, err := exchangeCode(code, verifier, clientID)
|
||||||
|
if err != nil {
|
||||||
|
c.fail(400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idToken, _ := buildIDToken(claims, clientID)
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"access_token": pair.AccessToken,
|
||||||
|
"refresh_token": pair.RefreshToken,
|
||||||
|
"token_type": pair.TokenType,
|
||||||
|
"expires_in": pair.ExpiresIn,
|
||||||
|
"id_token": idToken,
|
||||||
|
"sid": pair.Sid,
|
||||||
|
})
|
||||||
|
case GrantRefresh:
|
||||||
|
refresh := strings.TrimSpace(c.GetString("refresh_token"))
|
||||||
|
if refresh == "" || clientID == "" {
|
||||||
|
c.fail(400, "invalid_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pair, err := authsvc.RefreshTokens(refresh, clientID)
|
||||||
|
if err != nil {
|
||||||
|
c.fail(400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"access_token": pair.AccessToken,
|
||||||
|
"refresh_token": pair.RefreshToken,
|
||||||
|
"token_type": pair.TokenType,
|
||||||
|
"expires_in": pair.ExpiresIn,
|
||||||
|
"sid": pair.Sid,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.fail(400, "unsupported_grant_type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfo 用户信息端点,需 Bearer Token
|
||||||
|
// GET /auth/userinfo
|
||||||
|
func (c *AuthOidcController) UserInfo() {
|
||||||
|
claims := claimsFromHeader(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.Ctx.Output.SetStatus(401)
|
||||||
|
c.serveJSON(map[string]interface{}{"error": "invalid_token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if claims.UserID <= 0 {
|
||||||
|
c.fail(401, "invalid_token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var identity models.AuthIdentity
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", claims.UserID).One(&identity); err != nil {
|
||||||
|
c.fail(401, "invalid_token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
profile, err := authsvc.BuildProfile(&identity)
|
||||||
|
if err != nil {
|
||||||
|
c.fail(500, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"sub": fmt.Sprintf("%d", identity.ID),
|
||||||
|
"union_id": identity.UnionID,
|
||||||
|
"tid": claims.TenantId,
|
||||||
|
"nickname": profile.Nickname,
|
||||||
|
"mobile": profile.Mobile,
|
||||||
|
"email": profile.Email,
|
||||||
|
"avatar": profile.Avatar,
|
||||||
|
"tenants": profile.Tenants,
|
||||||
|
"client_id": claims.ClientID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Introspect 令牌校验,供资源服务(各业务后端)调用
|
||||||
|
// POST /auth/token 之外的独立端点;比本地 JWKS 验签更实时(可查黑名单)
|
||||||
|
func (c *AuthOidcController) Introspect() {
|
||||||
|
token := strings.TrimSpace(c.GetString("token"))
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(c.Ctx.Request.FormValue("token"))
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
c.fail(400, "invalid_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := jwtutil.ParseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"active": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if authsvc.IsBlacklisted(claims.ID) {
|
||||||
|
c.serveJSON(map[string]interface{}{"active": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"active": true,
|
||||||
|
"sub": claims.Subject,
|
||||||
|
"user_id": claims.UserID,
|
||||||
|
"tid": claims.TenantId,
|
||||||
|
"client_id": claims.ClientID,
|
||||||
|
"sid": claims.Sid,
|
||||||
|
"scope": claims.Scope,
|
||||||
|
"amr": claims.Amr,
|
||||||
|
"exp": claims.ExpiresAt.Unix(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 吊销令牌(登出/踢下线)
|
||||||
|
// POST /auth/revoke
|
||||||
|
func (c *AuthOidcController) Revoke() {
|
||||||
|
token := strings.TrimSpace(c.GetString("token"))
|
||||||
|
refresh := strings.TrimSpace(c.GetString("refresh_token"))
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(c.Ctx.Request.FormValue("token"))
|
||||||
|
}
|
||||||
|
if token == "" && refresh == "" {
|
||||||
|
c.fail(400, "invalid_request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = authsvc.RevokeTokenPair(refresh, token, models.RevokeReasonLogout)
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已吊销"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 内部工具
|
||||||
|
|
||||||
|
func authorizePath(c *AuthOidcController) string {
|
||||||
|
return "/auth/authorize"
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearSessionCookie 清除认证中心会话 Cookie
|
||||||
|
func clearSessionCookie(c *AuthOidcController) {
|
||||||
|
c.Ctx.Output.Header("Set-Cookie",
|
||||||
|
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSessionCookie 写入认证中心会话 Cookie
|
||||||
|
func setSessionCookie(c *AuthOidcController, sid string) {
|
||||||
|
c.Ctx.Output.Header("Set-Cookie",
|
||||||
|
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
|
||||||
|
sessionCookieName, sid, sessionCookieTTL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken(c *AuthOidcController) string {
|
||||||
|
header := c.Ctx.Request.Header.Get("Authorization")
|
||||||
|
if header == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(header, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimsFromHeader(c *AuthOidcController) *jwtutil.Claims {
|
||||||
|
token := bearerToken(c)
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
claims, err := jwtutil.ParseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
// findClient 查询启用状态的应用
|
||||||
|
func findClient(clientID string) (*models.AuthClient, error) {
|
||||||
|
var client models.AuthClient
|
||||||
|
err := models.Orm.QueryTable(new(models.AuthClient)).
|
||||||
|
Filter("client_id", clientID).
|
||||||
|
Filter("status", 1).
|
||||||
|
One(&client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowRedirect 校验回跳地址是否在白名单内(精确匹配,防钓鱼)
|
||||||
|
func allowRedirect(client *models.AuthClient, uri string) bool {
|
||||||
|
if client.RedirectURIs == nil || *client.RedirectURIs == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
if err := json.Unmarshal([]byte(*client.RedirectURIs), &list); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
if strings.TrimSpace(item) == strings.TrimSpace(uri) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueAuthCode 生成一次性授权码(明文返回,库中只存哈希)
|
||||||
|
func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) {
|
||||||
|
plain, err := randomString(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(plain))
|
||||||
|
code := &models.AuthCode{
|
||||||
|
CodeHash: fmt.Sprintf("%x", sum[:]),
|
||||||
|
ClientID: clientID,
|
||||||
|
IdentityID: identityID,
|
||||||
|
Tid: tid,
|
||||||
|
RedirectURI: redirectURI,
|
||||||
|
CodeChallenge: challenge,
|
||||||
|
CodeChallengeMethod: method,
|
||||||
|
ExpiresAt: time.Now().Add(60 * time.Second),
|
||||||
|
}
|
||||||
|
if scope != "" {
|
||||||
|
code.Scope = &scope
|
||||||
|
}
|
||||||
|
if nonce != "" {
|
||||||
|
code.Nonce = &nonce
|
||||||
|
}
|
||||||
|
if _, err := models.Orm.Insert(code); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeCode 用授权码换令牌(校验 PKCE、一次性、有效期)
|
||||||
|
func exchangeCode(code, verifier, clientID string) (*authsvc.TokenPair, *jwtutil.Claims, error) {
|
||||||
|
sum := sha256.Sum256([]byte(code))
|
||||||
|
var stored models.AuthCode
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthCode)).
|
||||||
|
Filter("code_hash", fmt.Sprintf("%x", sum[:])).One(&stored); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("invalid_grant")
|
||||||
|
}
|
||||||
|
if stored.Used != 0 || stored.ExpiresAt.Before(time.Now()) {
|
||||||
|
return nil, nil, fmt.Errorf("invalid_grant")
|
||||||
|
}
|
||||||
|
if stored.ClientID != clientID {
|
||||||
|
return nil, nil, fmt.Errorf("invalid_client")
|
||||||
|
}
|
||||||
|
if !verifyPKCE(verifier, stored.CodeChallenge, stored.CodeChallengeMethod) {
|
||||||
|
return nil, nil, fmt.Errorf("invalid_grant")
|
||||||
|
}
|
||||||
|
// 一次性:立即标记已用
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthCode)).
|
||||||
|
Filter("code_hash", stored.CodeHash).
|
||||||
|
Update(map[string]interface{}{"used": 1})
|
||||||
|
|
||||||
|
session, err := authsvc.CreateSession(authsvc.SessionInfo{
|
||||||
|
IdentityID: stored.IdentityID,
|
||||||
|
Tid: stored.Tid,
|
||||||
|
ClientID: clientID,
|
||||||
|
LoginType: authsvc.LoginTypePassword,
|
||||||
|
Amr: authsvc.AmrPwd,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := findClient(clientID)
|
||||||
|
accessTTL := 1800
|
||||||
|
refreshTTL := 2592000
|
||||||
|
if err == nil {
|
||||||
|
accessTTL = client.AccessTTL
|
||||||
|
refreshTTL = client.RefreshTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
pair, err := authsvc.IssueTokens(authsvc.TokenIssue{
|
||||||
|
IdentityID: stored.IdentityID,
|
||||||
|
Tid: stored.Tid,
|
||||||
|
ClientID: clientID,
|
||||||
|
Sid: session.Sid,
|
||||||
|
UserType: "tenant",
|
||||||
|
Amr: authsvc.AmrPwd,
|
||||||
|
AccessTTL: accessTTL,
|
||||||
|
RefreshTTL: refreshTTL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := jwtutil.ParseToken(pair.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return pair, claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyPKCE 校验 PKCE(S256 或 plain)
|
||||||
|
func verifyPKCE(verifier, challenge, method string) bool {
|
||||||
|
if challenge == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if method == "S256" {
|
||||||
|
sum := sha256.Sum256([]byte(verifier))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(sum[:]) == challenge
|
||||||
|
}
|
||||||
|
return verifier == challenge
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildIDToken 生成 OIDC ID Token
|
||||||
|
func buildIDToken(claims *jwtutil.Claims, clientID string) (string, error) {
|
||||||
|
return jwtutil.SignToken(jwtutil.TokenOptions{
|
||||||
|
Alg: jwtutil.AlgRS256,
|
||||||
|
UserID: claims.UserID,
|
||||||
|
TenantID: claims.TenantId,
|
||||||
|
UserType: claims.UserType,
|
||||||
|
ClientID: clientID,
|
||||||
|
Sid: claims.Sid,
|
||||||
|
Subject: claims.Subject,
|
||||||
|
Audience: []string{clientID},
|
||||||
|
Amr: claims.Amr,
|
||||||
|
TTL: time.Hour,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomString 生成随机串
|
||||||
|
func randomString(n int) (string, error) {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,362 +1,436 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"log"
|
||||||
"strconv"
|
"math/rand"
|
||||||
"strings"
|
"strconv"
|
||||||
"time"
|
"strings"
|
||||||
|
"time"
|
||||||
"server/models"
|
|
||||||
"server/pkg/passwordutil"
|
"server/models"
|
||||||
"server/services"
|
"server/pkg/passwordutil"
|
||||||
|
"server/services"
|
||||||
"github.com/beego/beego/v2/client/orm"
|
authsvc "server/services/auth"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
|
||||||
)
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
// PlatformTenantUserController 平台租户用户绑定管理
|
)
|
||||||
type PlatformTenantUserController struct {
|
|
||||||
beego.Controller
|
// syncTenantUserToAuth 把租户用户同步到统一认证中心(双写)。
|
||||||
}
|
// 同步失败只记日志,不影响平台端主流程(认证中心为新增能力)。
|
||||||
|
func syncTenantUserToAuth(row *models.SystemTenantUser) {
|
||||||
type tenantUserPayload struct {
|
if row == nil {
|
||||||
Tid uint64 `json:"tid"`
|
return
|
||||||
Uid uint64 `json:"uid"`
|
}
|
||||||
Account *string `json:"account"`
|
in := authsvc.SyncTenantUserInput{
|
||||||
Name *string `json:"name"`
|
Tid: row.Tid,
|
||||||
Phone *string `json:"phone"`
|
GroupID: row.GroupID,
|
||||||
Email *string `json:"email"`
|
OrgID: row.OrgID,
|
||||||
Password *string `json:"password"`
|
Status: row.Status,
|
||||||
IsDefault *int8 `json:"is_default"`
|
IsDefault: row.IsDefault,
|
||||||
Status *int8 `json:"status"`
|
}
|
||||||
OrgID *uint64 `json:"org_id"`
|
if row.Account != nil {
|
||||||
Remark *string `json:"remark"`
|
in.Account = strings.TrimSpace(*row.Account)
|
||||||
}
|
}
|
||||||
|
if row.Name != nil {
|
||||||
// GetTenantUserList 获取绑定列表(支持按 tid / uid 过滤,keyword 对姓名/手机/邮箱/账号模糊匹配)
|
in.Name = strings.TrimSpace(*row.Name)
|
||||||
// GET /platform/tenantUser/list?tid=1&uid=2&keyword=xxx
|
}
|
||||||
func (c *PlatformTenantUserController) GetTenantUserList() {
|
if row.Phone != nil {
|
||||||
tid, _ := c.GetUint64("tid")
|
in.Phone = strings.TrimSpace(*row.Phone)
|
||||||
uid, _ := c.GetUint64("uid")
|
}
|
||||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
if row.Email != nil {
|
||||||
|
in.Email = strings.TrimSpace(*row.Email)
|
||||||
qs := models.Orm.QueryTable(new(models.SystemTenantUser))
|
}
|
||||||
|
if row.Password != nil {
|
||||||
var cond *orm.Condition
|
in.PasswordHash = *row.Password
|
||||||
needCond := false
|
}
|
||||||
if tid > 0 {
|
if err := authsvc.SyncTenantUser(in); err != nil {
|
||||||
if cond == nil {
|
log.Printf("[auth] 同步租户用户到认证中心失败 tid=%d account=%s: %v", row.Tid, in.Account, err)
|
||||||
cond = orm.NewCondition()
|
}
|
||||||
}
|
}
|
||||||
cond = cond.And("tid", tid)
|
|
||||||
needCond = true
|
// removeTenantUserFromAuth 删除认证中心中的企业绑定(保留身份)
|
||||||
}
|
func removeTenantUserFromAuth(row *models.SystemTenantUser) {
|
||||||
if uid > 0 {
|
if row == nil {
|
||||||
if cond == nil {
|
return
|
||||||
cond = orm.NewCondition()
|
}
|
||||||
}
|
account, phone, email := "", "", ""
|
||||||
cond = cond.And("uid", uid)
|
if row.Account != nil {
|
||||||
needCond = true
|
account = strings.TrimSpace(*row.Account)
|
||||||
}
|
}
|
||||||
if keyword != "" {
|
if row.Phone != nil {
|
||||||
kwCond := orm.NewCondition()
|
phone = strings.TrimSpace(*row.Phone)
|
||||||
kwCond = kwCond.Or("name__icontains", keyword).
|
}
|
||||||
Or("phone__icontains", keyword).
|
if row.Email != nil {
|
||||||
Or("email__icontains", keyword).
|
email = strings.TrimSpace(*row.Email)
|
||||||
Or("account__icontains", keyword)
|
}
|
||||||
if cond == nil {
|
if err := authsvc.RemoveTenantUser(row.Tid, account, phone, email); err != nil {
|
||||||
cond = kwCond
|
log.Printf("[auth] 删除认证中心企业绑定失败 tid=%d account=%s: %v", row.Tid, account, err)
|
||||||
} else {
|
}
|
||||||
cond = cond.AndCond(kwCond)
|
}
|
||||||
}
|
|
||||||
needCond = true
|
// PlatformTenantUserController 平台租户用户绑定管理
|
||||||
}
|
type PlatformTenantUserController struct {
|
||||||
if needCond {
|
beego.Controller
|
||||||
qs = qs.SetCond(cond)
|
}
|
||||||
}
|
|
||||||
|
type tenantUserPayload struct {
|
||||||
var rows []models.SystemTenantUser
|
Tid uint64 `json:"tid"`
|
||||||
_, err := qs.OrderBy("-is_default", "-id").All(&rows)
|
Uid uint64 `json:"uid"`
|
||||||
if err != nil {
|
Account *string `json:"account"`
|
||||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()}
|
Name *string `json:"name"`
|
||||||
_ = c.ServeJSON()
|
Phone *string `json:"phone"`
|
||||||
return
|
Email *string `json:"email"`
|
||||||
}
|
Password *string `json:"password"`
|
||||||
|
IsDefault *int8 `json:"is_default"`
|
||||||
c.Data["json"] = map[string]interface{}{
|
Status *int8 `json:"status"`
|
||||||
"code": 200,
|
OrgID *uint64 `json:"org_id"`
|
||||||
"msg": "success",
|
Remark *string `json:"remark"`
|
||||||
"data": map[string]interface{}{
|
}
|
||||||
"list": rows,
|
|
||||||
"total": len(rows),
|
// GetTenantUserList 获取绑定列表(支持按 tid / uid 过滤,keyword 对姓名/手机/邮箱/账号模糊匹配)
|
||||||
},
|
// GET /platform/tenantUser/list?tid=1&uid=2&keyword=xxx
|
||||||
}
|
func (c *PlatformTenantUserController) GetTenantUserList() {
|
||||||
_ = c.ServeJSON()
|
tid, _ := c.GetUint64("tid")
|
||||||
}
|
uid, _ := c.GetUint64("uid")
|
||||||
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
// GetTenantUsersByTid 兼容旧路由,根据租户 ID 获取租户用户列表
|
|
||||||
// GET /platform/getTenantUsers/:tid
|
qs := models.Orm.QueryTable(new(models.SystemTenantUser))
|
||||||
func (c *PlatformTenantUserController) GetTenantUsersByTid() {
|
|
||||||
tidStr := c.Ctx.Input.Param(":tid")
|
var cond *orm.Condition
|
||||||
tid, _ := strconv.ParseUint(tidStr, 10, 64)
|
needCond := false
|
||||||
if tid == 0 {
|
if tid > 0 {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"}
|
if cond == nil {
|
||||||
_ = c.ServeJSON()
|
cond = orm.NewCondition()
|
||||||
return
|
}
|
||||||
}
|
cond = cond.And("tid", tid)
|
||||||
var rows []models.SystemTenantUser
|
needCond = true
|
||||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
}
|
||||||
Filter("tid", tid).
|
if uid > 0 {
|
||||||
OrderBy("-is_default", "-id").
|
if cond == nil {
|
||||||
All(&rows)
|
cond = orm.NewCondition()
|
||||||
if err != nil {
|
}
|
||||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()}
|
cond = cond.And("uid", uid)
|
||||||
_ = c.ServeJSON()
|
needCond = true
|
||||||
return
|
}
|
||||||
}
|
if keyword != "" {
|
||||||
c.Data["json"] = map[string]interface{}{
|
kwCond := orm.NewCondition()
|
||||||
"code": 200,
|
kwCond = kwCond.Or("name__icontains", keyword).
|
||||||
"msg": "success",
|
Or("phone__icontains", keyword).
|
||||||
"data": map[string]interface{}{"list": rows, "total": len(rows)},
|
Or("email__icontains", keyword).
|
||||||
}
|
Or("account__icontains", keyword)
|
||||||
_ = c.ServeJSON()
|
if cond == nil {
|
||||||
}
|
cond = kwCond
|
||||||
|
} else {
|
||||||
// GetTenantUserDetail 获取绑定详情
|
cond = cond.AndCond(kwCond)
|
||||||
// GET /platform/tenantUser/detail/:id
|
}
|
||||||
func (c *PlatformTenantUserController) GetTenantUserDetail() {
|
needCond = true
|
||||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
}
|
||||||
if err != nil || id == 0 {
|
if needCond {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
|
qs = qs.SetCond(cond)
|
||||||
_ = c.ServeJSON()
|
}
|
||||||
return
|
|
||||||
}
|
var rows []models.SystemTenantUser
|
||||||
|
_, err := qs.OrderBy("-is_default", "-id").All(&rows)
|
||||||
var row models.SystemTenantUser
|
if err != nil {
|
||||||
err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row)
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()}
|
||||||
if err != nil {
|
_ = c.ServeJSON()
|
||||||
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "记录不存在"}
|
return
|
||||||
_ = c.ServeJSON()
|
}
|
||||||
return
|
|
||||||
}
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row}
|
"msg": "success",
|
||||||
_ = c.ServeJSON()
|
"data": map[string]interface{}{
|
||||||
}
|
"list": rows,
|
||||||
|
"total": len(rows),
|
||||||
// CreateTenantUser 创建租户用户绑定(写入 yz_system_tenant_user;uid 为空时自动生成)
|
},
|
||||||
// POST /platform/tenantUser/create
|
}
|
||||||
func (c *PlatformTenantUserController) CreateTenantUser() {
|
_ = c.ServeJSON()
|
||||||
p, ok := c.parsePayload()
|
}
|
||||||
if !ok {
|
|
||||||
return
|
// GetTenantUsersByTid 兼容旧路由,根据租户 ID 获取租户用户列表
|
||||||
}
|
// GET /platform/getTenantUsers/:tid
|
||||||
if p.Tid == 0 {
|
func (c *PlatformTenantUserController) GetTenantUsersByTid() {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"}
|
tidStr := c.Ctx.Input.Param(":tid")
|
||||||
_ = c.ServeJSON()
|
tid, _ := strconv.ParseUint(tidStr, 10, 64)
|
||||||
return
|
if tid == 0 {
|
||||||
}
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"}
|
||||||
if p.Account == nil || strings.TrimSpace(*p.Account) == "" {
|
_ = c.ServeJSON()
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "account 不能为空"}
|
return
|
||||||
_ = c.ServeJSON()
|
}
|
||||||
return
|
var rows []models.SystemTenantUser
|
||||||
}
|
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
if p.Password == nil || strings.TrimSpace(*p.Password) == "" {
|
Filter("tid", tid).
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"}
|
OrderBy("-is_default", "-id").
|
||||||
_ = c.ServeJSON()
|
All(&rows)
|
||||||
return
|
if err != nil {
|
||||||
}
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()}
|
||||||
hashed, err := passwordutil.Hash(*p.Password)
|
_ = c.ServeJSON()
|
||||||
if err != nil {
|
return
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
}
|
||||||
_ = c.ServeJSON()
|
c.Data["json"] = map[string]interface{}{
|
||||||
return
|
"code": 200,
|
||||||
}
|
"msg": "success",
|
||||||
p.Password = &hashed
|
"data": map[string]interface{}{"list": rows, "total": len(rows)},
|
||||||
if p.Uid == 0 {
|
}
|
||||||
uid, err := generateTenantUID(p.Tid)
|
_ = c.ServeJSON()
|
||||||
if err != nil {
|
}
|
||||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成租户用户ID失败"}
|
|
||||||
_ = c.ServeJSON()
|
// GetTenantUserDetail 获取绑定详情
|
||||||
return
|
// GET /platform/tenantUser/detail/:id
|
||||||
}
|
func (c *PlatformTenantUserController) GetTenantUserDetail() {
|
||||||
p.Uid = uid
|
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||||
}
|
if err != nil || id == 0 {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
|
||||||
// 用户数配额校验:租户用户数达到上限时不允许新增(增购用户数后放开)
|
_ = c.ServeJSON()
|
||||||
if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil {
|
return
|
||||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()}
|
}
|
||||||
_ = c.ServeJSON()
|
|
||||||
return
|
var row models.SystemTenantUser
|
||||||
} else if !check.Allowed {
|
err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row)
|
||||||
c.Data["json"] = map[string]interface{}{
|
if err != nil {
|
||||||
"code": 400,
|
c.Data["json"] = map[string]interface{}{"code": 404, "msg": "记录不存在"}
|
||||||
"msg": fmt.Sprintf("该租户用户数已达上限(%d/%d),请先为租户增购用户数", check.Used, check.Quota),
|
_ = c.ServeJSON()
|
||||||
}
|
return
|
||||||
_ = c.ServeJSON()
|
}
|
||||||
return
|
|
||||||
}
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": row}
|
||||||
|
_ = c.ServeJSON()
|
||||||
isDefault := int8(0)
|
}
|
||||||
status := int8(1)
|
|
||||||
if p.IsDefault != nil {
|
// CreateTenantUser 创建租户用户绑定(写入 yz_system_tenant_user;uid 为空时自动生成)
|
||||||
isDefault = *p.IsDefault
|
// POST /platform/tenantUser/create
|
||||||
}
|
func (c *PlatformTenantUserController) CreateTenantUser() {
|
||||||
if p.Status != nil {
|
p, ok := c.parsePayload()
|
||||||
status = *p.Status
|
if !ok {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
id, err := services.BindTenantUser(p.Tid, p.Uid, p.Account, p.Name, p.Phone, p.Email, nil, nil, p.Password, isDefault, status, p.Remark)
|
if p.Tid == 0 {
|
||||||
if err != nil {
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "tid 不能为空"}
|
||||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()}
|
_ = c.ServeJSON()
|
||||||
_ = c.ServeJSON()
|
return
|
||||||
return
|
}
|
||||||
}
|
if p.Account == nil || strings.TrimSpace(*p.Account) == "" {
|
||||||
if p.OrgID != nil && *p.OrgID > 0 {
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "account 不能为空"}
|
||||||
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
_ = c.ServeJSON()
|
||||||
Filter("id", id).
|
return
|
||||||
Update(map[string]interface{}{"org_id": *p.OrgID})
|
}
|
||||||
}
|
if p.Password == nil || strings.TrimSpace(*p.Password) == "" {
|
||||||
if isDefault == 1 {
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "password 不能为空"}
|
||||||
_ = services.SetDefaultTenant(p.Uid, p.Tid)
|
_ = c.ServeJSON()
|
||||||
// 租户的第一个账号即租户管理员:自动绑定全局「租户管理员」角色(拥有 backend 全权限)。
|
return
|
||||||
if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 {
|
}
|
||||||
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
hashed, err := passwordutil.Hash(*p.Password)
|
||||||
Filter("id", id).
|
if err != nil {
|
||||||
Update(map[string]interface{}{"group_id": adminRoleID})
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
||||||
}
|
_ = c.ServeJSON()
|
||||||
}
|
return
|
||||||
|
}
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
|
p.Password = &hashed
|
||||||
_ = c.ServeJSON()
|
if p.Uid == 0 {
|
||||||
}
|
uid, err := generateTenantUID(p.Tid)
|
||||||
|
if err != nil {
|
||||||
// EditTenantUser 编辑绑定
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "生成租户用户ID失败"}
|
||||||
// POST /platform/tenantUser/edit/:id
|
_ = c.ServeJSON()
|
||||||
func (c *PlatformTenantUserController) EditTenantUser() {
|
return
|
||||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
}
|
||||||
if err != nil || id == 0 {
|
p.Uid = uid
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
|
}
|
||||||
_ = c.ServeJSON()
|
|
||||||
return
|
// 用户数配额校验:租户用户数达到上限时不允许新增(增购用户数后放开)
|
||||||
}
|
if check, err := services.CheckTenantUserQuota(p.Tid, p.Uid); err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "用户数校验失败: " + err.Error()}
|
||||||
p, ok := c.parsePayload()
|
_ = c.ServeJSON()
|
||||||
if !ok {
|
return
|
||||||
return
|
} else if !check.Allowed {
|
||||||
}
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 400,
|
||||||
update := map[string]interface{}{}
|
"msg": fmt.Sprintf("该租户用户数已达上限(%d/%d),请先为租户增购用户数", check.Used, check.Quota),
|
||||||
if p.Tid > 0 {
|
}
|
||||||
update["tid"] = p.Tid
|
_ = c.ServeJSON()
|
||||||
}
|
return
|
||||||
if p.Uid > 0 {
|
}
|
||||||
update["uid"] = p.Uid
|
|
||||||
}
|
isDefault := int8(0)
|
||||||
if p.Account != nil {
|
status := int8(1)
|
||||||
update["account"] = p.Account
|
if p.IsDefault != nil {
|
||||||
}
|
isDefault = *p.IsDefault
|
||||||
if p.Name != nil {
|
}
|
||||||
update["name"] = p.Name
|
if p.Status != nil {
|
||||||
}
|
status = *p.Status
|
||||||
if p.Phone != nil {
|
}
|
||||||
update["phone"] = p.Phone
|
|
||||||
}
|
id, err := services.BindTenantUser(p.Tid, p.Uid, p.Account, p.Name, p.Phone, p.Email, nil, nil, p.Password, isDefault, status, p.Remark)
|
||||||
if p.Email != nil {
|
if err != nil {
|
||||||
update["email"] = p.Email
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()}
|
||||||
}
|
_ = c.ServeJSON()
|
||||||
if p.Password != nil {
|
return
|
||||||
hashed, err := passwordutil.Hash(*p.Password)
|
}
|
||||||
if err != nil {
|
if p.OrgID != nil && *p.OrgID > 0 {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
_ = c.ServeJSON()
|
Filter("id", id).
|
||||||
return
|
Update(map[string]interface{}{"org_id": *p.OrgID})
|
||||||
}
|
}
|
||||||
update["password"] = hashed
|
if isDefault == 1 {
|
||||||
}
|
_ = services.SetDefaultTenant(p.Uid, p.Tid)
|
||||||
if p.IsDefault != nil {
|
// 租户的第一个账号即租户管理员:自动绑定全局「租户管理员」角色(拥有 backend 全权限)。
|
||||||
update["is_default"] = *p.IsDefault
|
if adminRoleID, err := models.GetTenantAdminRole(); err == nil && adminRoleID > 0 {
|
||||||
}
|
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
if p.Status != nil {
|
Filter("id", id).
|
||||||
update["status"] = *p.Status
|
Update(map[string]interface{}{"group_id": adminRoleID})
|
||||||
}
|
}
|
||||||
if p.OrgID != nil {
|
}
|
||||||
update["org_id"] = *p.OrgID
|
|
||||||
}
|
// 双写:同步到统一认证中心(读回最新记录,确保 group_id/org_id 已落库)
|
||||||
if p.Remark != nil {
|
var created models.SystemTenantUser
|
||||||
update["remark"] = p.Remark
|
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); err == nil {
|
||||||
}
|
syncTenantUserToAuth(&created)
|
||||||
|
}
|
||||||
if len(update) == 0 {
|
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"}
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
// EditTenantUser 编辑绑定
|
||||||
_, err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).Update(update)
|
// POST /platform/tenantUser/edit/:id
|
||||||
if err != nil {
|
func (c *PlatformTenantUserController) EditTenantUser() {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()}
|
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||||
_ = c.ServeJSON()
|
if err != nil || id == 0 {
|
||||||
return
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
|
||||||
}
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
if p.IsDefault != nil && *p.IsDefault == 1 && p.Uid > 0 && p.Tid > 0 {
|
}
|
||||||
_ = services.SetDefaultTenant(p.Uid, p.Tid)
|
|
||||||
}
|
p, ok := c.parsePayload()
|
||||||
|
if !ok {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
|
return
|
||||||
_ = c.ServeJSON()
|
}
|
||||||
}
|
|
||||||
|
update := map[string]interface{}{}
|
||||||
// DeleteTenantUser 删除绑定
|
if p.Tid > 0 {
|
||||||
// DELETE /platform/tenantUser/delete/:id
|
update["tid"] = p.Tid
|
||||||
func (c *PlatformTenantUserController) DeleteTenantUser() {
|
}
|
||||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
if p.Uid > 0 {
|
||||||
if err != nil || id == 0 {
|
update["uid"] = p.Uid
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
|
}
|
||||||
_ = c.ServeJSON()
|
if p.Account != nil {
|
||||||
return
|
update["account"] = p.Account
|
||||||
}
|
}
|
||||||
|
if p.Name != nil {
|
||||||
if err := services.UnbindTenantUser(id); err != nil {
|
update["name"] = p.Name
|
||||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()}
|
}
|
||||||
_ = c.ServeJSON()
|
if p.Phone != nil {
|
||||||
return
|
update["phone"] = p.Phone
|
||||||
}
|
}
|
||||||
|
if p.Email != nil {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
|
update["email"] = p.Email
|
||||||
_ = c.ServeJSON()
|
}
|
||||||
}
|
if p.Password != nil {
|
||||||
|
hashed, err := passwordutil.Hash(*p.Password)
|
||||||
func (c *PlatformTenantUserController) parsePayload() (tenantUserPayload, bool) {
|
if err != nil {
|
||||||
var p tenantUserPayload
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
||||||
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
_ = c.ServeJSON()
|
||||||
if err := json.Unmarshal(raw, &p); err != nil {
|
return
|
||||||
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"}
|
}
|
||||||
_ = c.ServeJSON()
|
update["password"] = hashed
|
||||||
return tenantUserPayload{}, false
|
}
|
||||||
}
|
if p.IsDefault != nil {
|
||||||
return p, true
|
update["is_default"] = *p.IsDefault
|
||||||
}
|
}
|
||||||
|
if p.Status != nil {
|
||||||
func generateTenantUID(tid uint64) (uint64, error) {
|
update["status"] = *p.Status
|
||||||
rand.Seed(time.Now().UnixNano())
|
}
|
||||||
for i := 0; i < 8; i++ {
|
if p.OrgID != nil {
|
||||||
uid := uint64(10000000 + rand.Intn(90000000))
|
update["org_id"] = *p.OrgID
|
||||||
cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
}
|
||||||
Filter("tid", tid).
|
if p.Remark != nil {
|
||||||
Filter("uid", uid).
|
update["remark"] = p.Remark
|
||||||
Count()
|
}
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
if len(update) == 0 {
|
||||||
}
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无更新字段"}
|
||||||
if cnt == 0 {
|
_ = c.ServeJSON()
|
||||||
return uid, nil
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return 0, errors.New("uid collision")
|
_, err = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).Update(update)
|
||||||
}
|
if err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.IsDefault != nil && *p.IsDefault == 1 && p.Uid > 0 && p.Tid > 0 {
|
||||||
|
_ = services.SetDefaultTenant(p.Uid, p.Tid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双写:同步到统一认证中心
|
||||||
|
var updated models.SystemTenantUser
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&updated); err == nil {
|
||||||
|
syncTenantUserToAuth(&updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTenantUser 删除绑定
|
||||||
|
// DELETE /platform/tenantUser/delete/:id
|
||||||
|
func (c *PlatformTenantUserController) DeleteTenantUser() {
|
||||||
|
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "无效ID"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除前先读出记录,供认证中心侧定位(删除后就读不到了)
|
||||||
|
var row models.SystemTenantUser
|
||||||
|
_ = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row)
|
||||||
|
|
||||||
|
if err := services.UnbindTenantUser(id); err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双写:删除认证中心中的企业绑定
|
||||||
|
removeTenantUserFromAuth(&row)
|
||||||
|
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformTenantUserController) parsePayload() (tenantUserPayload, bool) {
|
||||||
|
var p tenantUserPayload
|
||||||
|
raw, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": "参数错误"}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return tenantUserPayload{}, false
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateTenantUID(tid uint64) (uint64, error) {
|
||||||
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
uid := uint64(10000000 + rand.Intn(90000000))
|
||||||
|
cnt, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("uid", uid).
|
||||||
|
Count()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if cnt == 0 {
|
||||||
|
return uid, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, errors.New("uid collision")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- 统一认证中心(UAC)表结构 数据库:go-platform
|
||||||
|
-- 路径:api.yunzer.cn/auth
|
||||||
|
--
|
||||||
|
-- 设计要点:
|
||||||
|
-- 1. yz_auth_identity 为「自然人」层,密码上提到这一层;
|
||||||
|
-- 2. yz_auth_tenant_user 为「身份-企业」绑定,一人可在多家企业任职;
|
||||||
|
-- 3. 密码列存 PHC 格式($argon2id$...),盐内联,无需独立 salt 列;
|
||||||
|
-- 历史数据为 salt$sha256(legacy),首次登录时自动升级;
|
||||||
|
-- 4. 会话/刷新令牌/授权码均存哈希,明文不下发到数据库;
|
||||||
|
-- 5. 本脚本只建表不写数据,数据迁移由 scripts/migrate_auth.go 完成。
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- ---------------- 1. 统一身份(自然人) ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_identity` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '身份ID',
|
||||||
|
`union_id` VARCHAR(32) NOT NULL COMMENT '全局唯一标识(32位随机串)',
|
||||||
|
`mobile` VARCHAR(20) DEFAULT NULL COMMENT '手机号,全局唯一',
|
||||||
|
`email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱,全局唯一',
|
||||||
|
`password_algo` VARCHAR(16) NOT NULL DEFAULT 'argon2id' COMMENT 'argon2id/legacy',
|
||||||
|
`password_hash` VARCHAR(255) DEFAULT NULL COMMENT 'PHC格式,含盐与参数',
|
||||||
|
`password_ver` INT NOT NULL DEFAULT 1 COMMENT '密码版本,改密时+1用于踢下线',
|
||||||
|
`nickname` VARCHAR(64) DEFAULT NULL,
|
||||||
|
`avatar` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`mfa_enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否开启二次验证',
|
||||||
|
`mfa_secret` VARCHAR(128) DEFAULT NULL COMMENT 'TOTP密钥(加密存储)',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用/0禁用/2锁定',
|
||||||
|
`fail_count` INT NOT NULL DEFAULT 0 COMMENT '连续失败次数',
|
||||||
|
`locked_until` DATETIME DEFAULT NULL COMMENT '锁定截止时间',
|
||||||
|
`last_login_at` DATETIME DEFAULT NULL,
|
||||||
|
`last_login_ip` VARCHAR(45) DEFAULT NULL,
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
`delete_time` DATETIME DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_union_id` (`union_id`),
|
||||||
|
UNIQUE KEY `uk_mobile` (`mobile`),
|
||||||
|
UNIQUE KEY `uk_email` (`email`),
|
||||||
|
KEY `idx_status` (`status`),
|
||||||
|
KEY `idx_create_time` (`create_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-身份表';
|
||||||
|
|
||||||
|
-- ---------------- 2. 身份-企业绑定(一人多企) ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_tenant_user` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`identity_id` BIGINT UNSIGNED NOT NULL COMMENT '身份ID',
|
||||||
|
`account` VARCHAR(64) DEFAULT NULL COMMENT '企业内账号,可空(用手机号登录)',
|
||||||
|
`name` VARCHAR(64) DEFAULT NULL COMMENT '在该企业的姓名',
|
||||||
|
`phone` VARCHAR(20) DEFAULT NULL,
|
||||||
|
`email` VARCHAR(128) DEFAULT NULL,
|
||||||
|
`group_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '角色ID(cid=2的租户角色)',
|
||||||
|
`org_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '部门ID',
|
||||||
|
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认企业',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用/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_identity` (`tid`, `identity_id`),
|
||||||
|
KEY `idx_identity` (`identity_id`),
|
||||||
|
KEY `idx_tid_account` (`tid`, `account`),
|
||||||
|
KEY `idx_tid_status` (`tid`, `status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-身份企业绑定';
|
||||||
|
|
||||||
|
-- ---------------- 3. 接入应用(OIDC Client) ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_client` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`client_id` VARCHAR(64) NOT NULL,
|
||||||
|
`client_secret` VARCHAR(128) DEFAULT NULL COMMENT '机密客户端才有,存哈希',
|
||||||
|
`app_code` VARCHAR(32) NOT NULL COMMENT 'crm/oa/uniapp/website...',
|
||||||
|
`name` VARCHAR(128) NOT NULL,
|
||||||
|
`app_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1Web后端 2SPA 3原生APP 4小程序',
|
||||||
|
`redirect_uris` TEXT COMMENT 'JSON数组,精确匹配白名单',
|
||||||
|
`post_logout_uris` TEXT COMMENT '登出后允许回跳的地址',
|
||||||
|
`backchannel_logout_uri` VARCHAR(500) DEFAULT NULL COMMENT '单点登出回调(服务端通知)',
|
||||||
|
`grant_types` VARCHAR(255) NOT NULL DEFAULT 'authorization_code,refresh_token',
|
||||||
|
`scope` VARCHAR(255) DEFAULT 'openid,profile,tenant',
|
||||||
|
`access_ttl` INT NOT NULL DEFAULT 1800 COMMENT '访问令牌有效期(秒)',
|
||||||
|
`refresh_ttl` INT NOT NULL DEFAULT 2592000 COMMENT '刷新令牌有效期(秒)',
|
||||||
|
`realm` VARCHAR(16) NOT NULL DEFAULT 'tenant' COMMENT 'tenant/platform',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 1,
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_client_id` (`client_id`),
|
||||||
|
UNIQUE KEY `uk_app_code` (`app_code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-接入应用';
|
||||||
|
|
||||||
|
-- ---------------- 4. 登录会话(1号1机 / 强制下线) ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_session` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`sid` VARCHAR(64) NOT NULL COMMENT '会话ID,写入JWT的sid',
|
||||||
|
`identity_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前所选企业',
|
||||||
|
`client_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`device_id` VARCHAR(64) DEFAULT NULL,
|
||||||
|
`device_name` VARCHAR(128) DEFAULT NULL,
|
||||||
|
`ip` VARCHAR(45) DEFAULT NULL,
|
||||||
|
`user_agent` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`login_type` VARCHAR(20) NOT NULL DEFAULT 'password',
|
||||||
|
`amr` VARCHAR(64) DEFAULT NULL COMMENT 'pwd/sms/otp/wx/dingtalk/feishu/qq/github/google',
|
||||||
|
`login_at` DATETIME NOT NULL,
|
||||||
|
`last_access_at` DATETIME NOT NULL,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`revoked` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`revoke_reason` VARCHAR(64) DEFAULT NULL COMMENT 'logout/kicked/admin/expired',
|
||||||
|
`revoke_at` DATETIME DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_sid` (`sid`),
|
||||||
|
KEY `idx_identity_status` (`identity_id`, `revoked`),
|
||||||
|
KEY `idx_tid` (`tid`),
|
||||||
|
KEY `idx_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-登录会话';
|
||||||
|
|
||||||
|
-- ---------------- 5. 刷新令牌(轮换 + 重放检测) ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_refresh_token` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`token_hash` VARCHAR(128) NOT NULL COMMENT '不透明串的哈希,明文不下发落库',
|
||||||
|
`identity_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`client_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`sid` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`family_id` VARCHAR(64) NOT NULL COMMENT '轮换家族,检测到重放则整族吊销',
|
||||||
|
`rotated_from` VARCHAR(128) DEFAULT NULL,
|
||||||
|
`used` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`revoked` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_token_hash` (`token_hash`),
|
||||||
|
KEY `idx_family` (`family_id`),
|
||||||
|
KEY `idx_identity` (`identity_id`),
|
||||||
|
KEY `idx_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-刷新令牌';
|
||||||
|
|
||||||
|
-- ---------------- 6. 授权码(一次性,60秒) ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_code` (
|
||||||
|
`code_hash` VARCHAR(128) NOT NULL COMMENT '授权码哈希',
|
||||||
|
`client_id` VARCHAR(64) NOT NULL,
|
||||||
|
`identity_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`redirect_uri` VARCHAR(500) NOT NULL,
|
||||||
|
`code_challenge` VARCHAR(128) NOT NULL COMMENT 'PKCE',
|
||||||
|
`code_challenge_method` VARCHAR(10) NOT NULL DEFAULT 'S256',
|
||||||
|
`scope` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`nonce` VARCHAR(128) DEFAULT NULL COMMENT 'OIDC nonce,进ID Token',
|
||||||
|
`used` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`expires_at` DATETIME NOT NULL,
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`code_hash`),
|
||||||
|
KEY `idx_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-授权码';
|
||||||
|
|
||||||
|
-- ---------------- 7. 令牌吊销表(登出/踢下线) ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_token_blacklist` (
|
||||||
|
`jti` VARCHAR(64) NOT NULL COMMENT 'JWT ID',
|
||||||
|
`identity_id` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
`sid` VARCHAR(64) DEFAULT NULL,
|
||||||
|
`reason` VARCHAR(64) DEFAULT NULL,
|
||||||
|
`expires_at` DATETIME NOT NULL COMMENT '原token过期时间,到期后可清理',
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`jti`),
|
||||||
|
KEY `idx_expires` (`expires_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-令牌吊销表';
|
||||||
|
|
||||||
|
-- ---------------- 8. 第三方身份绑定 ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_identity_third` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`identity_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`provider` VARCHAR(32) NOT NULL COMMENT 'wechat/dingtalk/feishu/qq/github/google',
|
||||||
|
`open_id` VARCHAR(128) NOT NULL COMMENT '该平台内的唯一ID',
|
||||||
|
`union_id` VARCHAR(128) DEFAULT NULL COMMENT '跨平台唯一ID(微信/钉钉有)',
|
||||||
|
`nickname` VARCHAR(128) DEFAULT NULL,
|
||||||
|
`avatar` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`raw` TEXT COMMENT '原始用户信息JSON',
|
||||||
|
`bind_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_provider_open` (`provider`, `open_id`),
|
||||||
|
KEY `idx_identity` (`identity_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-第三方绑定';
|
||||||
|
|
||||||
|
-- ---------------- 9. 租户自带身份源(企业微信/钉钉/飞书/OIDC) ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_tenant_idp` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`provider` VARCHAR(32) NOT NULL COMMENT 'dingtalk/feishu/wework/oidc/saml',
|
||||||
|
`name` VARCHAR(128) DEFAULT NULL COMMENT '显示名称',
|
||||||
|
`app_id` VARCHAR(128) DEFAULT NULL,
|
||||||
|
`app_secret` VARCHAR(512) DEFAULT NULL COMMENT '加密存储',
|
||||||
|
`issuer` VARCHAR(500) DEFAULT NULL COMMENT 'OIDC/SAML issuer',
|
||||||
|
`auth_url` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`token_url` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`userinfo_url` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`jwks_url` VARCHAR(500) DEFAULT NULL,
|
||||||
|
`scopes` VARCHAR(255) DEFAULT NULL,
|
||||||
|
`attr_map` TEXT COMMENT 'JSON:字段映射(手机/姓名/部门)',
|
||||||
|
`proxy_url` VARCHAR(500) DEFAULT NULL COMMENT 'GitHub/Google等需要代理时填写',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 1,
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_tid_provider` (`tid`, `provider`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-租户身份源';
|
||||||
|
|
||||||
|
-- ---------------- 10. 租户登录策略 ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_tenant_auth_config` (
|
||||||
|
`tid` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`verify_type` VARCHAR(20) NOT NULL DEFAULT 'captcha' COMMENT 'none/captcha/sms/email/geetest',
|
||||||
|
`open_verify` TINYINT NOT NULL DEFAULT 1 COMMENT '是否开启登录二次验证',
|
||||||
|
`pwd_min_len` INT NOT NULL DEFAULT 8,
|
||||||
|
`pwd_complexity` TINYINT NOT NULL DEFAULT 0 COMMENT '0不限制 1字母+数字 2大小写+数字+符号',
|
||||||
|
`session_ttl` INT NOT NULL DEFAULT 7200 COMMENT '会话有效期(秒)',
|
||||||
|
`max_session` INT NOT NULL DEFAULT 1 COMMENT '同账号最大同时在线数(默认1号1机)',
|
||||||
|
`kick_strategy` TINYINT NOT NULL DEFAULT 1 COMMENT '1踢掉旧会话 2拒绝新登录',
|
||||||
|
`mfa_required` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`ip_whitelist` TEXT COMMENT 'JSON数组,为空表示不限制',
|
||||||
|
`allow_third` VARCHAR(255) DEFAULT NULL COMMENT '允许的第三方登录,逗号分隔,空表示全部',
|
||||||
|
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`tid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-租户登录策略';
|
||||||
|
|
||||||
|
-- ---------------- 11. 统一登录日志 ----------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_auth_login_log` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`tid` BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
`identity_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
`account` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`user_name` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`tenant_name` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`client_id` VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
|
`login_type` VARCHAR(20) NOT NULL DEFAULT 'password' COMMENT 'password/sms/otp/third',
|
||||||
|
`amr` VARCHAR(64) DEFAULT NULL,
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1成功/0失败',
|
||||||
|
`message` VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
`ip` VARCHAR(45) NOT NULL DEFAULT '',
|
||||||
|
`user_agent` VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_tid` (`tid`),
|
||||||
|
KEY `idx_identity` (`identity_id`),
|
||||||
|
KEY `idx_account` (`account`),
|
||||||
|
KEY `idx_create_time` (`create_time`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一认证-登录日志';
|
||||||
|
|
||||||
|
-- =============================================================
|
||||||
|
-- 内置接入应用(后续由 platform 端「应用注册」页面维护)
|
||||||
|
-- client_secret 为占位值,正式启用前必须在管理端重新生成
|
||||||
|
-- =============================================================
|
||||||
|
INSERT INTO `yz_auth_client`
|
||||||
|
(`client_id`, `app_code`, `name`, `app_type`, `grant_types`, `scope`, `access_ttl`, `refresh_ttl`, `realm`)
|
||||||
|
VALUES
|
||||||
|
('yz-backend', 'backend', '租户管理后台', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'),
|
||||||
|
('yz-uniapp', 'uniapp', '移动端APP', 3, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'),
|
||||||
|
('yz-website', 'website', '企业官网', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant')
|
||||||
|
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||||
+198
-75
@@ -1,75 +1,198 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"log"
|
||||||
|
"net/http"
|
||||||
"server/pkg/jwtutil"
|
"strings"
|
||||||
|
"sync"
|
||||||
"github.com/beego/beego/v2/server/web"
|
|
||||||
"github.com/beego/beego/v2/server/web/context"
|
"server/pkg/jwtutil"
|
||||||
)
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
// JWTAuthMiddleware JWT认证中间件
|
"github.com/beego/beego/v2/server/web/context"
|
||||||
func JWTAuthMiddleware() web.FilterFunc {
|
)
|
||||||
return func(ctx *context.Context) {
|
|
||||||
// 跳过登录相关的路由
|
// 鉴权开关(app.conf: auth_enforce)
|
||||||
if strings.HasPrefix(ctx.Request.RequestURI, "/api/login") ||
|
// - off :完全不启用(等同改造前的行为)
|
||||||
strings.HasPrefix(ctx.Request.RequestURI, "/api/reset-password") {
|
// - warn :观察模式,只记录「无 token / token 无效」的接口,不拦截(默认,零风险上线)
|
||||||
return
|
// - on :真正拦截并返回 401
|
||||||
}
|
//
|
||||||
|
// 上线建议:先 warn 跑一段时间,用日志核对白名单是否遗漏,确认无误后再切 on。
|
||||||
// 从请求头中获取Authorization
|
const (
|
||||||
authHeader := ctx.Request.Header.Get("Authorization")
|
AuthModeOff = "off"
|
||||||
if authHeader == "" {
|
AuthModeWarn = "warn"
|
||||||
ctx.Output.SetStatus(401)
|
AuthModeOn = "on"
|
||||||
ctx.Output.JSON(map[string]interface{}{
|
)
|
||||||
"success": false,
|
|
||||||
"message": "未提供认证信息",
|
// protectedPrefixes 需要鉴权的业务前缀。
|
||||||
}, false, false)
|
// 仅保护业务 API:官网(/ /site/ /index/…)、开放 API(/api/…)、
|
||||||
return
|
// 静态资源与 ACME 验证路径均不在其中,避免误伤公开接口。
|
||||||
}
|
var protectedPrefixes = []string{"/backend/", "/platform/", "/app/"}
|
||||||
|
|
||||||
// 按空格分割
|
// authWhitelist 受保护前缀内无需鉴权的路径(前缀匹配)
|
||||||
authParts := strings.SplitN(authHeader, " ", 2)
|
var authWhitelist = []string{
|
||||||
if !(len(authParts) == 2 && authParts[0] == "Bearer") {
|
// 统一认证中心(P1 上线)
|
||||||
ctx.Output.SetStatus(401)
|
"/auth/",
|
||||||
ctx.Output.JSON(map[string]interface{}{
|
|
||||||
"success": false,
|
// backend 登录/注册/找回密码
|
||||||
"message": "认证信息格式错误",
|
"/backend/login",
|
||||||
}, false, false)
|
"/backend/sendLoginCode",
|
||||||
return
|
"/backend/register",
|
||||||
}
|
"/backend/sendRegisterCode",
|
||||||
|
"/backend/resetPassword",
|
||||||
// 解析token
|
"/backend/sendResetCode",
|
||||||
claims, err := jwtutil.ParseToken(authParts[1])
|
"/backend/verifyAccount",
|
||||||
if err != nil {
|
"/backend/logout",
|
||||||
// 处理各种错误情况
|
|
||||||
ctx.Output.SetStatus(401)
|
// platform 登录/找回密码
|
||||||
switch err.Error() {
|
"/platform/login",
|
||||||
case "token is expired":
|
"/platform/sendLoginCode",
|
||||||
ctx.Output.JSON(map[string]interface{}{
|
"/platform/resetPassword",
|
||||||
"success": false,
|
"/platform/logout",
|
||||||
"message": "token已过期",
|
// 平台开放接口(客户端更新通知,无需登录)
|
||||||
}, false, false)
|
"/platform/api/",
|
||||||
default:
|
|
||||||
ctx.Output.JSON(map[string]interface{}{
|
// app 登录/注册/找回密码
|
||||||
"success": false,
|
"/app/login",
|
||||||
"message": "无效的token",
|
"/app/sendLoginCode",
|
||||||
}, false, false)
|
"/app/register",
|
||||||
}
|
"/app/sendRegisterCode",
|
||||||
return
|
"/app/resetPassword",
|
||||||
}
|
"/app/sendResetCode",
|
||||||
|
"/app/verifyAccount",
|
||||||
// 将用户信息存储在上下文
|
"/app/logout",
|
||||||
ctx.Input.SetData("userId", claims.UserID)
|
}
|
||||||
ctx.Input.SetData("username", claims.Username)
|
|
||||||
ctx.Input.SetData("tenantId", claims.TenantId)
|
var (
|
||||||
|
modeOnce sync.Once
|
||||||
// 从token中获取用户类型(如果token中没有,则默认为"user")
|
modeVal = AuthModeWarn
|
||||||
userType := claims.UserType
|
warnSeen sync.Map // 观察模式下按 path 去重,避免日志刷屏
|
||||||
if userType == "" {
|
)
|
||||||
userType = "user"
|
|
||||||
}
|
func authMode() string {
|
||||||
ctx.Input.SetData("userType", userType)
|
modeOnce.Do(func() {
|
||||||
}
|
if v, err := beego.AppConfig.String("auth_enforce"); err == nil {
|
||||||
}
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case AuthModeOff, AuthModeWarn, AuthModeOn:
|
||||||
|
modeVal = strings.ToLower(strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 额外白名单:auth_whitelist = /backend/xxx,/app/yyy
|
||||||
|
if extra, err := beego.AppConfig.String("auth_whitelist"); err == nil && strings.TrimSpace(extra) != "" {
|
||||||
|
for _, p := range strings.Split(extra, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
authWhitelist = append(authWhitelist, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[auth] JWT 全局鉴权模式=%s,受保护前缀=%v", modeVal, protectedPrefixes)
|
||||||
|
})
|
||||||
|
return modeVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanPath 去掉 query / fragment 后返回纯路径
|
||||||
|
func cleanPath(uri string) string {
|
||||||
|
if i := strings.IndexAny(uri, "?#"); i >= 0 {
|
||||||
|
return uri[:i]
|
||||||
|
}
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProtected(path string) bool {
|
||||||
|
for _, p := range protectedPrefixes {
|
||||||
|
if strings.HasPrefix(path, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWhitelisted(path string) bool {
|
||||||
|
for _, p := range authWhitelist {
|
||||||
|
if strings.HasPrefix(path, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnLog 观察模式下记录一次「本会被拦截」的请求(按路径去重)
|
||||||
|
func warnLog(path, reason string) {
|
||||||
|
if _, loaded := warnSeen.LoadOrStore(path, struct{}{}); loaded {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[auth][warn] 该接口在 enforce=on 时会被拦截: %s (%s)", path, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTAuthMiddleware JWT认证中间件
|
||||||
|
func JWTAuthMiddleware() beego.FilterFunc {
|
||||||
|
return func(ctx *context.Context) {
|
||||||
|
mode := authMode()
|
||||||
|
if mode == AuthModeOff {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 预检请求直接放行
|
||||||
|
if ctx.Input.Method() == http.MethodOptions {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := cleanPath(ctx.Request.RequestURI)
|
||||||
|
if !isProtected(path) || isWhitelisted(path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败处理:观察模式只记日志,强制模式才真正拦截
|
||||||
|
reject := func(status int, message string) {
|
||||||
|
if mode == AuthModeWarn {
|
||||||
|
warnLog(path, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Output.SetStatus(status)
|
||||||
|
ctx.Output.JSON(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"message": message,
|
||||||
|
}, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从请求头中获取Authorization
|
||||||
|
authHeader := ctx.Request.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
reject(401, "未提供认证信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按空格分割
|
||||||
|
authParts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if !(len(authParts) == 2 && authParts[0] == "Bearer") {
|
||||||
|
reject(401, "认证信息格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析token
|
||||||
|
claims, err := jwtutil.ParseToken(authParts[1])
|
||||||
|
if err != nil {
|
||||||
|
message := "无效的token"
|
||||||
|
if strings.Contains(err.Error(), "expired") {
|
||||||
|
message = "token已过期"
|
||||||
|
}
|
||||||
|
reject(401, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将用户信息存储在上下文
|
||||||
|
ctx.Input.SetData("userId", claims.UserID)
|
||||||
|
ctx.Input.SetData("username", claims.Username)
|
||||||
|
ctx.Input.SetData("tenantId", claims.TenantId)
|
||||||
|
|
||||||
|
// 从token中获取用户类型(如果token中没有,则默认为"user")
|
||||||
|
userType := claims.UserType
|
||||||
|
if userType == "" {
|
||||||
|
userType = "user"
|
||||||
|
}
|
||||||
|
ctx.Input.SetData("userType", userType)
|
||||||
|
|
||||||
|
// 认证中心扩展字段(旧 token 为空值,不影响现有逻辑)
|
||||||
|
ctx.Input.SetData("clientId", claims.ClientID)
|
||||||
|
ctx.Input.SetData("sid", claims.Sid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
merge_key,row_count,password_variants,tids,representative_id
|
||||||
|
m:19895983967,2,2,1|2,4
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
merge_key,row_count,password_variants,tids,representative_id
|
||||||
|
m:19895983967,2,2,1|2,4
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
old_id,old_uid,tid,identity_id,tenant_user_id,account,merge_key,conflict
|
||||||
|
2,96085169,2,1,1,test1,m:13357867407,false
|
||||||
|
1,88888888,1,2,2,hero920103,m:19895983967,true
|
||||||
|
4,67091493,2,2,3,hero920103,m:19895983967,true
|
||||||
|
@@ -0,0 +1,43 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 接入应用类型
|
||||||
|
const (
|
||||||
|
AuthAppTypeWeb = 1 // 有后端的 Web 应用(可安全保存 secret)
|
||||||
|
AuthAppTypeSPA = 2 // 单页应用(只能走 PKCE,不存 secret)
|
||||||
|
AuthAppTypeNative = 3 // 原生 APP / uniapp
|
||||||
|
AuthAppTypeMiniProg = 4 // 小程序
|
||||||
|
)
|
||||||
|
|
||||||
|
// 领域隔离:租户域与平台域使用不同 realm,避免平台账号登录租户应用
|
||||||
|
const (
|
||||||
|
AuthRealmTenant = "tenant"
|
||||||
|
AuthRealmPlatform = "platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthClient 接入应用(OIDC Client):yz_auth_client
|
||||||
|
// 以后每开发一个新软件,只需在这里注册一条即可接入统一认证。
|
||||||
|
type AuthClient struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
ClientID string `orm:"column(client_id);size(64)" json:"client_id"`
|
||||||
|
ClientSecret *string `orm:"column(client_secret);size(128);null" json:"-"`
|
||||||
|
AppCode string `orm:"column(app_code);size(32)" json:"app_code"`
|
||||||
|
Name string `orm:"column(name);size(128)" json:"name"`
|
||||||
|
AppType int8 `orm:"column(app_type);default(1)" json:"app_type"`
|
||||||
|
RedirectURIs *string `orm:"column(redirect_uris);type(text);null" json:"redirect_uris"`
|
||||||
|
PostLogoutURIs *string `orm:"column(post_logout_uris);type(text);null" json:"post_logout_uris"`
|
||||||
|
BackchannelLogoutURI *string `orm:"column(backchannel_logout_uri);size(500);null" json:"backchannel_logout_uri"`
|
||||||
|
GrantTypes string `orm:"column(grant_types);size(255)" json:"grant_types"`
|
||||||
|
Scope *string `orm:"column(scope);size(255);null" json:"scope"`
|
||||||
|
AccessTTL int `orm:"column(access_ttl);default(1800)" json:"access_ttl"`
|
||||||
|
RefreshTTL int `orm:"column(refresh_ttl);default(2592000)" json:"refresh_ttl"`
|
||||||
|
Realm string `orm:"column(realm);size(16);default(tenant)" json:"realm"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthClient) TableName() string {
|
||||||
|
return "yz_auth_client"
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 身份状态
|
||||||
|
const (
|
||||||
|
AuthIdentityStatusEnabled = 1 // 启用
|
||||||
|
AuthIdentityStatusDisabled = 0 // 禁用
|
||||||
|
AuthIdentityStatusLocked = 2 // 锁定(连续失败过多)
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthIdentity 统一身份(自然人):yz_auth_identity
|
||||||
|
//
|
||||||
|
// 统一认证中心的核心表。密码上提到这一层,一个自然人一份密码,
|
||||||
|
// 通过 yz_auth_tenant_user 绑定到多家企业(一人可在多家企业任职)。
|
||||||
|
//
|
||||||
|
// password_hash 存储 PHC 格式($argon2id$v=19$m=...,t=...,p=...$salt$hash),
|
||||||
|
// 盐与参数内联,无需独立 salt 列;历史数据为 salt$sha256(legacy),
|
||||||
|
// 首次登录成功时自动重新哈希为 argon2id。
|
||||||
|
type AuthIdentity struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
UnionID string `orm:"column(union_id);size(32)" json:"union_id"`
|
||||||
|
Mobile *string `orm:"column(mobile);size(20);null" json:"mobile"`
|
||||||
|
Email *string `orm:"column(email);size(128);null" json:"email"`
|
||||||
|
PasswordAlgo string `orm:"column(password_algo);size(16);default(argon2id)" json:"password_algo"`
|
||||||
|
PasswordHash *string `orm:"column(password_hash);size(255);null" json:"-"`
|
||||||
|
PasswordVer int `orm:"column(password_ver);default(1)" json:"password_ver"`
|
||||||
|
Nickname *string `orm:"column(nickname);size(64);null" json:"nickname"`
|
||||||
|
Avatar *string `orm:"column(avatar);size(500);null" json:"avatar"`
|
||||||
|
MfaEnabled int8 `orm:"column(mfa_enabled);default(0)" json:"mfa_enabled"`
|
||||||
|
MfaSecret *string `orm:"column(mfa_secret);size(128);null" json:"-"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
FailCount int `orm:"column(fail_count);default(0)" json:"fail_count"`
|
||||||
|
LockedUntil *time.Time `orm:"column(locked_until);type(datetime);null" json:"locked_until"`
|
||||||
|
LastLoginAt *time.Time `orm:"column(last_login_at);type(datetime);null" json:"last_login_at"`
|
||||||
|
LastLoginIP *string `orm:"column(last_login_ip);size(45);null" json:"last_login_ip"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthIdentity) TableName() string {
|
||||||
|
return "yz_auth_identity"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三方登录 provider 常量
|
||||||
|
const (
|
||||||
|
IdPWechat = "wechat"
|
||||||
|
IdPDingTalk = "dingtalk"
|
||||||
|
IdPFeishu = "feishu"
|
||||||
|
IdPQQ = "qq"
|
||||||
|
IdPGitHub = "github"
|
||||||
|
IdPGoogle = "google"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthIdentityThird 第三方身份绑定:yz_auth_identity_third
|
||||||
|
// 同一身份可绑定多个第三方账号;同一 provider 的 open_id 全局唯一。
|
||||||
|
type AuthIdentityThird struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
|
||||||
|
Provider string `orm:"column(provider);size(32)" json:"provider"`
|
||||||
|
OpenID string `orm:"column(open_id);size(128)" json:"open_id"`
|
||||||
|
UnionID *string `orm:"column(union_id);size(128);null" json:"union_id"`
|
||||||
|
Nickname *string `orm:"column(nickname);size(128);null" json:"nickname"`
|
||||||
|
Avatar *string `orm:"column(avatar);size(500);null" json:"avatar"`
|
||||||
|
Raw *string `orm:"column(raw);type(text);null" json:"raw"`
|
||||||
|
BindTime time.Time `orm:"column(bind_time);auto_now_add;type(datetime)" json:"bind_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthIdentityThird) TableName() string {
|
||||||
|
return "yz_auth_identity_third"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AuthLoginLog 统一登录日志:yz_auth_login_log
|
||||||
|
// 认证中心上线后逐步替代 yz_system_login_log,补充 client_id / amr 字段。
|
||||||
|
type AuthLoginLog struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Tid *uint64 `orm:"column(tid);null" json:"tid"`
|
||||||
|
IdentityID *uint64 `orm:"column(identity_id);null" json:"identity_id"`
|
||||||
|
Account string `orm:"column(account);size(64);default('')" json:"account"`
|
||||||
|
UserName string `orm:"column(user_name);size(64);default('')" json:"user_name"`
|
||||||
|
TenantName string `orm:"column(tenant_name);size(64);default('')" json:"tenant_name"`
|
||||||
|
ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"`
|
||||||
|
LoginType string `orm:"column(login_type);size(20);default(password)" json:"login_type"`
|
||||||
|
Amr *string `orm:"column(amr);size(64);null" json:"amr"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
Message string `orm:"column(message);size(255);default('')" json:"message"`
|
||||||
|
IP string `orm:"column(ip);size(45);default('')" json:"ip"`
|
||||||
|
UserAgent string `orm:"column(user_agent);size(500);default('')" json:"user_agent"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthLoginLog) TableName() string {
|
||||||
|
return "yz_auth_login_log"
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 会话吊销原因
|
||||||
|
const (
|
||||||
|
RevokeReasonLogout = "logout" // 用户主动登出
|
||||||
|
RevokeReasonKicked = "kicked" // 被新登录踢下线(1号1机)
|
||||||
|
RevokeReasonAdmin = "admin" // 管理员强制下线
|
||||||
|
RevokeReasonExpired = "expired" // 过期清理
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthSession 登录会话:yz_auth_session
|
||||||
|
// 用于「1号1机」并发控制、在线设备列表、强制下线。
|
||||||
|
type AuthSession struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Sid string `orm:"column(sid);size(64)" json:"sid"`
|
||||||
|
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
|
||||||
|
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
|
||||||
|
ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"`
|
||||||
|
DeviceID *string `orm:"column(device_id);size(64);null" json:"device_id"`
|
||||||
|
DeviceName *string `orm:"column(device_name);size(128);null" json:"device_name"`
|
||||||
|
IP *string `orm:"column(ip);size(45);null" json:"ip"`
|
||||||
|
UserAgent *string `orm:"column(user_agent);size(500);null" json:"user_agent"`
|
||||||
|
LoginType string `orm:"column(login_type);size(20);default(password)" json:"login_type"`
|
||||||
|
Amr *string `orm:"column(amr);size(64);null" json:"amr"`
|
||||||
|
LoginAt time.Time `orm:"column(login_at);type(datetime)" json:"login_at"`
|
||||||
|
LastAccessAt time.Time `orm:"column(last_access_at);type(datetime)" json:"last_access_at"`
|
||||||
|
ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"`
|
||||||
|
Revoked int8 `orm:"column(revoked);default(0)" json:"revoked"`
|
||||||
|
RevokeReason *string `orm:"column(revoke_reason);size(64);null" json:"revoke_reason"`
|
||||||
|
RevokeAt *time.Time `orm:"column(revoke_at);type(datetime);null" json:"revoke_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthSession) TableName() string {
|
||||||
|
return "yz_auth_session"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthRefreshToken 刷新令牌:yz_auth_refresh_token
|
||||||
|
// 明文仅在颁发时返回一次,库中只存哈希;轮换时通过 family_id 检测重放。
|
||||||
|
type AuthRefreshToken struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
TokenHash string `orm:"column(token_hash);size(128)" json:"token_hash"`
|
||||||
|
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
|
||||||
|
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
|
||||||
|
ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"`
|
||||||
|
Sid string `orm:"column(sid);size(64);default('')" json:"sid"`
|
||||||
|
FamilyID string `orm:"column(family_id);size(64)" json:"family_id"`
|
||||||
|
RotatedFrom *string `orm:"column(rotated_from);size(128);null" json:"rotated_from"`
|
||||||
|
Used int8 `orm:"column(used);default(0)" json:"used"`
|
||||||
|
Revoked int8 `orm:"column(revoked);default(0)" json:"revoked"`
|
||||||
|
ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthRefreshToken) TableName() string {
|
||||||
|
return "yz_auth_refresh_token"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthCode 授权码:yz_auth_code
|
||||||
|
// 一次性、60 秒有效,配合 PKCE(S256)使用。
|
||||||
|
type AuthCode struct {
|
||||||
|
CodeHash string `orm:"column(code_hash);size(128);pk" json:"code_hash"`
|
||||||
|
ClientID string `orm:"column(client_id);size(64)" json:"client_id"`
|
||||||
|
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
|
||||||
|
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
|
||||||
|
RedirectURI string `orm:"column(redirect_uri);size(500)" json:"redirect_uri"`
|
||||||
|
CodeChallenge string `orm:"column(code_challenge);size(128)" json:"code_challenge"`
|
||||||
|
CodeChallengeMethod string `orm:"column(code_challenge_method);size(10);default(S256)" json:"code_challenge_method"`
|
||||||
|
Scope *string `orm:"column(scope);size(255);null" json:"scope"`
|
||||||
|
Nonce *string `orm:"column(nonce);size(128);null" json:"nonce"`
|
||||||
|
Used int8 `orm:"column(used);default(0)" json:"used"`
|
||||||
|
ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthCode) TableName() string {
|
||||||
|
return "yz_auth_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthTokenBlacklist 令牌吊销表:yz_auth_token_blacklist
|
||||||
|
// 记录已登出/被踢下线的 access token 的 jti,过期后可定期清理。
|
||||||
|
type AuthTokenBlacklist struct {
|
||||||
|
Jti string `orm:"column(jti);size(64);pk" json:"jti"`
|
||||||
|
IdentityID uint64 `orm:"column(identity_id);default(0)" json:"identity_id"`
|
||||||
|
Sid *string `orm:"column(sid);size(64);null" json:"sid"`
|
||||||
|
Reason *string `orm:"column(reason);size(64);null" json:"reason"`
|
||||||
|
ExpiresAt time.Time `orm:"column(expires_at);type(datetime)" json:"expires_at"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthTokenBlacklist) TableName() string {
|
||||||
|
return "yz_auth_token_blacklist"
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 租户自带身份源 provider
|
||||||
|
const (
|
||||||
|
TenantIdPDingTalk = "dingtalk"
|
||||||
|
TenantIdPFeishu = "feishu"
|
||||||
|
TenantIdPWeWork = "wework"
|
||||||
|
TenantIdPOIDC = "oidc"
|
||||||
|
TenantIdPSAML = "saml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthTenantIdp 租户自带身份源:yz_auth_tenant_idp
|
||||||
|
// 企业客户可配置自己的钉钉/飞书/企业微信或标准 OIDC/SAML 上游,
|
||||||
|
// 登录时按 tid 路由到对应身份源(第10条需求)。
|
||||||
|
type AuthTenantIdp struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||||
|
Provider string `orm:"column(provider);size(32)" json:"provider"`
|
||||||
|
Name *string `orm:"column(name);size(128);null" json:"name"`
|
||||||
|
AppID *string `orm:"column(app_id);size(128);null" json:"app_id"`
|
||||||
|
AppSecret *string `orm:"column(app_secret);size(512);null" json:"-"`
|
||||||
|
Issuer *string `orm:"column(issuer);size(500);null" json:"issuer"`
|
||||||
|
AuthURL *string `orm:"column(auth_url);size(500);null" json:"auth_url"`
|
||||||
|
TokenURL *string `orm:"column(token_url);size(500);null" json:"token_url"`
|
||||||
|
UserinfoURL *string `orm:"column(userinfo_url);size(500);null" json:"userinfo_url"`
|
||||||
|
JwksURL *string `orm:"column(jwks_url);size(500);null" json:"jwks_url"`
|
||||||
|
Scopes *string `orm:"column(scopes);size(255);null" json:"scopes"`
|
||||||
|
AttrMap *string `orm:"column(attr_map);type(text);null" json:"attr_map"`
|
||||||
|
ProxyURL *string `orm:"column(proxy_url);size(500);null" json:"proxy_url"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthTenantIdp) TableName() string {
|
||||||
|
return "yz_auth_tenant_idp"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发超限策略
|
||||||
|
const (
|
||||||
|
KickStrategyKickOld = 1 // 踢掉旧会话(默认)
|
||||||
|
KickStrategyReject = 2 // 拒绝新登录
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthTenantAuthConfig 租户登录策略:yz_auth_tenant_auth_config
|
||||||
|
// 每个租户可自定义验证码方式、密码强度、会话时长与并发设备数。
|
||||||
|
type AuthTenantAuthConfig struct {
|
||||||
|
Tid uint64 `orm:"column(tid);pk" json:"tid"`
|
||||||
|
VerifyType string `orm:"column(verify_type);size(20);default(captcha)" json:"verify_type"`
|
||||||
|
OpenVerify int8 `orm:"column(open_verify);default(1)" json:"open_verify"`
|
||||||
|
PwdMinLen int `orm:"column(pwd_min_len);default(8)" json:"pwd_min_len"`
|
||||||
|
PwdComplexity int8 `orm:"column(pwd_complexity);default(0)" json:"pwd_complexity"`
|
||||||
|
SessionTTL int `orm:"column(session_ttl);default(7200)" json:"session_ttl"`
|
||||||
|
MaxSession int `orm:"column(max_session);default(1)" json:"max_session"`
|
||||||
|
KickStrategy int8 `orm:"column(kick_strategy);default(1)" json:"kick_strategy"`
|
||||||
|
MfaRequired int8 `orm:"column(mfa_required);default(0)" json:"mfa_required"`
|
||||||
|
IPWhitelist *string `orm:"column(ip_whitelist);type(text);null" json:"ip_whitelist"`
|
||||||
|
AllowThird *string `orm:"column(allow_third);size(255);null" json:"allow_third"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthTenantAuthConfig) TableName() string {
|
||||||
|
return "yz_auth_tenant_auth_config"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AuthTenantUser 身份-企业绑定:yz_auth_tenant_user
|
||||||
|
//
|
||||||
|
// 一个 AuthIdentity 可有多条绑定(在多家企业任职),各企业内
|
||||||
|
// 独立维护姓名、部门、角色与启用状态;密码不属于这一层。
|
||||||
|
type AuthTenantUser struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Tid uint64 `orm:"column(tid)" json:"tid"`
|
||||||
|
IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
|
||||||
|
Account *string `orm:"column(account);size(64);null" json:"account"`
|
||||||
|
Name *string `orm:"column(name);size(64);null" json:"name"`
|
||||||
|
Phone *string `orm:"column(phone);size(20);null" json:"phone"`
|
||||||
|
Email *string `orm:"column(email);size(128);null" json:"email"`
|
||||||
|
GroupID uint64 `orm:"column(group_id);default(0)" json:"group_id"`
|
||||||
|
OrgID uint64 `orm:"column(org_id);default(0)" json:"org_id"`
|
||||||
|
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthTenantUser) TableName() string {
|
||||||
|
return "yz_auth_tenant_user"
|
||||||
|
}
|
||||||
+15
-2
@@ -97,6 +97,8 @@ func Init(_ string) {
|
|||||||
new(TenantCrmContract),
|
new(TenantCrmContract),
|
||||||
new(TenantCrmPayback),
|
new(TenantCrmPayback),
|
||||||
new(TenantCrmPaybackRecord),
|
new(TenantCrmPaybackRecord),
|
||||||
|
// 平台更新通知(docs/sql/platform_upgrade.sql)
|
||||||
|
new(PlatformUpgradeNotice),
|
||||||
new(ErpAccountSet),
|
new(ErpAccountSet),
|
||||||
new(ErpNormalSetting),
|
new(ErpNormalSetting),
|
||||||
new(ErpCompanyContact),
|
new(ErpCompanyContact),
|
||||||
@@ -176,6 +178,19 @@ func Init(_ string) {
|
|||||||
new(WechatMpConfig),
|
new(WechatMpConfig),
|
||||||
new(WechatMpFollower),
|
new(WechatMpFollower),
|
||||||
new(WechatMpVerifyCode),
|
new(WechatMpVerifyCode),
|
||||||
|
|
||||||
|
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
|
||||||
|
new(AuthIdentity),
|
||||||
|
new(AuthIdentityThird),
|
||||||
|
new(AuthTenantUser),
|
||||||
|
new(AuthClient),
|
||||||
|
new(AuthSession),
|
||||||
|
new(AuthRefreshToken),
|
||||||
|
new(AuthCode),
|
||||||
|
new(AuthTokenBlacklist),
|
||||||
|
new(AuthTenantIdp),
|
||||||
|
new(AuthTenantAuthConfig),
|
||||||
|
new(AuthLoginLog),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 创建全局 Ormer
|
// 创建全局 Ormer
|
||||||
@@ -192,5 +207,3 @@ func Init(_ string) {
|
|||||||
// 新建与调整走 SQL:docs/sql/update_role_system.sql,代码不做自动补建。
|
// 新建与调整走 SQL:docs/sql/update_role_system.sql,代码不做自动补建。
|
||||||
Orm = orm.NewOrm()
|
Orm = orm.NewOrm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PlatformUpgradeNotice 平台更新通知记录表:yz_platform_upgrade_notice
|
||||||
|
// 用于展示平台近期更新内容,支持按时间排序展示
|
||||||
|
type PlatformUpgradeNotice struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
TenantID string `orm:"column(tenant_id);size(64)" json:"tenant_id"` // 租户 ID,0 表示全局
|
||||||
|
UpdateDate string `orm:"column(update_date);size(20)" json:"update_date"` // 更新日期 YYYY-MM-DD
|
||||||
|
Title string `orm:"column(title);size(255)" json:"title"` // 更新标题
|
||||||
|
Content string `orm:"column(content);type(text)" json:"content"` // 更新内容详情
|
||||||
|
Sort int `orm:"column(sort);default(0)" json:"sort"` // 排序权重,越大越靠前
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"` // 状态:0-隐藏 1-显示
|
||||||
|
CreateUserID string `orm:"column(create_user_id);size(64)" json:"create_user_id"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PlatformUpgradeNotice) TableName() string {
|
||||||
|
return "yz_platform_upgrade_notice"
|
||||||
|
}
|
||||||
+321
-63
@@ -1,63 +1,321 @@
|
|||||||
package jwtutil
|
package jwtutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"crypto/rsa"
|
||||||
"time"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"fmt"
|
||||||
)
|
"math/big"
|
||||||
|
"os"
|
||||||
// 密钥(后续可从配置中读取)
|
"strings"
|
||||||
var secret = []byte("yunzer_jwt_secret_key")
|
"sync"
|
||||||
|
"time"
|
||||||
// Claims 定义JWT的claims结构
|
|
||||||
type Claims struct {
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
UserID int `json:"user_id"`
|
"github.com/golang-jwt/jwt/v5"
|
||||||
Username string `json:"username"`
|
)
|
||||||
TenantId int `json:"tenant_id"` // 租户ID
|
|
||||||
UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等
|
// 算法常量
|
||||||
jwt.RegisteredClaims
|
const (
|
||||||
}
|
AlgHS256 = "HS256"
|
||||||
|
AlgRS256 = "RS256"
|
||||||
// GenerateToken 生成JWT token
|
)
|
||||||
func GenerateToken(userID int, username string, tenantId int, userType string) (string, error) {
|
|
||||||
expirationTime := time.Now().Add(24 * time.Hour)
|
// DefaultKid 默认密钥 ID。历史 token 未携带 kid,统一按此 ID 处理。
|
||||||
|
const DefaultKid = "default"
|
||||||
claims := &Claims{
|
|
||||||
UserID: userID,
|
// legacySecret 兼容用的内置密钥:仅用于解析历史已签发的 token。
|
||||||
Username: username,
|
// 生产环境必须在 app.conf 中配置 jwt_secret,否则会一直使用该固定值。
|
||||||
TenantId: tenantId,
|
const legacySecret = "yunzer_jwt_secret_key"
|
||||||
UserType: userType,
|
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
// Claims JWT 载荷。旧字段保持不变,新增字段均为 omitempty,
|
||||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
// 历史 token 解析后为零值,不影响现有 78 处解析点。
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
type Claims struct {
|
||||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
UserID int `json:"user_id"`
|
||||||
},
|
Username string `json:"username"`
|
||||||
}
|
TenantId int `json:"tenant_id"` // 租户ID
|
||||||
|
UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
||||||
tokenString, err := token.SignedString(secret)
|
// ---- 统一认证中心(OIDC)扩展字段 ----
|
||||||
return tokenString, err
|
ClientID string `json:"client_id,omitempty"` // 接入应用 client_id,即 aud 的业务标识
|
||||||
}
|
Sid string `json:"sid,omitempty"` // 会话ID,用于单点登出/踢下线
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
// ParseToken 解析JWT token
|
Amr string `json:"amr,omitempty"` // 认证方式:pwd/sms/otp/wx/...
|
||||||
func ParseToken(tokenString string) (*Claims, error) {
|
|
||||||
claims := &Claims{}
|
jwt.RegisteredClaims
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
}
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
||||||
return nil, errors.New("unexpected signing method")
|
// ---------------------------------------------------------------- 密钥管理
|
||||||
}
|
|
||||||
return secret, nil
|
var (
|
||||||
})
|
keyOnce sync.Once
|
||||||
|
hsKeys map[string][]byte // kid -> HMAC 密钥
|
||||||
if err != nil {
|
rsaPriv *rsa.PrivateKey // RS256 签名
|
||||||
return nil, err
|
rsaPub *rsa.PublicKey // RS256 验签
|
||||||
}
|
rsaKid string // RS256 密钥 ID
|
||||||
|
issuerVal string
|
||||||
if !token.Valid {
|
)
|
||||||
return nil, errors.New("invalid token")
|
|
||||||
}
|
func loadKeys() {
|
||||||
|
hsKeys = map[string][]byte{DefaultKid: []byte(legacySecret)}
|
||||||
return claims, nil
|
|
||||||
}
|
// 主密钥(覆盖内置默认值)
|
||||||
|
if s, _ := beego.AppConfig.String("jwt_secret"); strings.TrimSpace(s) != "" {
|
||||||
|
hsKeys[DefaultKid] = []byte(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
// 轮换密钥:jwt_secrets = kid1:secret1,kid2:secret2
|
||||||
|
if s, _ := beego.AppConfig.String("jwt_secrets"); strings.TrimSpace(s) != "" {
|
||||||
|
for _, item := range strings.Split(s, ",") {
|
||||||
|
kv := strings.SplitN(strings.TrimSpace(item), ":", 2)
|
||||||
|
if len(kv) == 2 && strings.TrimSpace(kv[0]) != "" && strings.TrimSpace(kv[1]) != "" {
|
||||||
|
hsKeys[strings.TrimSpace(kv[0])] = []byte(strings.TrimSpace(kv[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RS256 密钥对:优先读文件路径,其次读内联 PEM
|
||||||
|
privPEM := readKeyConf("jwt_rsa_private_key", "jwt_rsa_private_key_file")
|
||||||
|
pubPEM := readKeyConf("jwt_rsa_public_key", "jwt_rsa_public_key_file")
|
||||||
|
if privPEM != "" {
|
||||||
|
if k, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privPEM)); err == nil {
|
||||||
|
rsaPriv = k
|
||||||
|
rsaPub = &k.PublicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pubPEM != "" {
|
||||||
|
if k, err := jwt.ParseRSAPublicKeyFromPEM([]byte(pubPEM)); err == nil {
|
||||||
|
rsaPub = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rsaKid == "" {
|
||||||
|
rsaKid, _ = beego.AppConfig.String("jwt_rsa_kid")
|
||||||
|
}
|
||||||
|
if rsaKid == "" {
|
||||||
|
rsaKid = "rsa-1"
|
||||||
|
}
|
||||||
|
issuerVal, _ = beego.AppConfig.String("jwt_issuer")
|
||||||
|
}
|
||||||
|
|
||||||
|
func readKeyConf(inlineKey, fileKey string) string {
|
||||||
|
if v, _ := beego.AppConfig.String(inlineKey); strings.TrimSpace(v) != "" {
|
||||||
|
return strings.ReplaceAll(strings.TrimSpace(v), `\n`, "\n")
|
||||||
|
}
|
||||||
|
if p, _ := beego.AppConfig.String(fileKey); strings.TrimSpace(p) != "" {
|
||||||
|
if b, err := os.ReadFile(strings.TrimSpace(p)); err == nil {
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureKeys() {
|
||||||
|
keyOnce.Do(loadKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyFunc 按 token header 的 alg + kid 选择验签密钥。
|
||||||
|
// 未携带 kid 或 kid 未注册时回落到默认密钥,保证历史 token 仍可解析。
|
||||||
|
func keyFunc(token *jwt.Token) (interface{}, error) {
|
||||||
|
ensureKeys()
|
||||||
|
kid, _ := token.Header["kid"].(string)
|
||||||
|
switch token.Method.Alg() {
|
||||||
|
case AlgHS256:
|
||||||
|
if sec, ok := hsKeys[kid]; ok && kid != "" {
|
||||||
|
return sec, nil
|
||||||
|
}
|
||||||
|
return hsKeys[DefaultKid], nil
|
||||||
|
case AlgRS256:
|
||||||
|
if rsaPub == nil {
|
||||||
|
return nil, errors.New("服务端未配置 RS256 公钥")
|
||||||
|
}
|
||||||
|
return rsaPub, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 签发
|
||||||
|
|
||||||
|
// TokenOptions 认证中心签发参数
|
||||||
|
type TokenOptions struct {
|
||||||
|
Alg string // HS256 / RS256;留空时优先 RS256,未配置 RSA 则回落 HS256
|
||||||
|
UserID int // 身份 ID(OIDC sub 用字符串形式)
|
||||||
|
Username string
|
||||||
|
TenantID int
|
||||||
|
UserType string
|
||||||
|
ClientID string
|
||||||
|
Sid string
|
||||||
|
Scope string
|
||||||
|
Amr string
|
||||||
|
Subject string
|
||||||
|
Audience []string
|
||||||
|
Jti string // JWT ID,用于吊销(登出/踢下线)
|
||||||
|
TTL time.Duration // 留空默认 30 分钟
|
||||||
|
Kid string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignToken 签发 token。RSA 未配置时自动回落 HS256,保证服务可启动。
|
||||||
|
func SignToken(opt TokenOptions) (string, error) {
|
||||||
|
ensureKeys()
|
||||||
|
|
||||||
|
alg := strings.ToUpper(strings.TrimSpace(opt.Alg))
|
||||||
|
if alg == "" {
|
||||||
|
alg = AlgRS256
|
||||||
|
}
|
||||||
|
if alg == AlgRS256 && rsaPriv == nil {
|
||||||
|
alg = AlgHS256
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := opt.TTL
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 30 * time.Minute
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: opt.UserID,
|
||||||
|
Username: opt.Username,
|
||||||
|
TenantId: opt.TenantID,
|
||||||
|
UserType: opt.UserType,
|
||||||
|
ClientID: opt.ClientID,
|
||||||
|
Sid: opt.Sid,
|
||||||
|
Scope: opt.Scope,
|
||||||
|
Amr: opt.Amr,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ID: opt.Jti,
|
||||||
|
Subject: opt.Subject,
|
||||||
|
Audience: opt.Audience,
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if issuerVal != "" {
|
||||||
|
claims.Issuer = issuerVal
|
||||||
|
}
|
||||||
|
if opt.Subject == "" && opt.UserID > 0 {
|
||||||
|
claims.Subject = fmt.Sprintf("%d", opt.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var method jwt.SigningMethod
|
||||||
|
switch alg {
|
||||||
|
case AlgRS256:
|
||||||
|
method = jwt.SigningMethodRS256
|
||||||
|
default:
|
||||||
|
method = jwt.SigningMethodHS256
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(method, claims)
|
||||||
|
|
||||||
|
kid := strings.TrimSpace(opt.Kid)
|
||||||
|
if kid == "" && alg == AlgRS256 {
|
||||||
|
kid = rsaKid
|
||||||
|
}
|
||||||
|
if kid != "" {
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
}
|
||||||
|
|
||||||
|
var key interface{}
|
||||||
|
if alg == AlgRS256 {
|
||||||
|
key = rsaPriv
|
||||||
|
} else {
|
||||||
|
kidToUse := kid
|
||||||
|
if kidToUse == "" {
|
||||||
|
kidToUse = DefaultKid
|
||||||
|
}
|
||||||
|
if sec, ok := hsKeys[kidToUse]; ok {
|
||||||
|
key = sec
|
||||||
|
} else {
|
||||||
|
key = hsKeys[DefaultKid]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token.SignedString(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken 生成 JWT token(兼容旧签名与行为)。
|
||||||
|
// 默认 HS256;若 app.conf 配置了 jwt_issuer 则带上 iss。
|
||||||
|
func GenerateToken(userID int, username string, tenantId int, userType string) (string, error) {
|
||||||
|
ttl := 24 * time.Hour
|
||||||
|
ensureKeys()
|
||||||
|
now := time.Now()
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
TenantId: tenantId,
|
||||||
|
UserType: userType,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if issuerVal != "" {
|
||||||
|
claims.Issuer = issuerVal
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(hsKeys[DefaultKid])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 解析
|
||||||
|
|
||||||
|
// ParseToken 解析并校验 JWT。自动识别 HS256 / RS256,函数签名与旧版一致。
|
||||||
|
func ParseToken(tokenString string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
_, err := jwt.ParseWithClaims(tokenString, claims, keyFunc,
|
||||||
|
jwt.WithValidMethods([]string{AlgHS256, AlgRS256}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTokenRaw 解析 token 但不校验有效期,用于登出/吊销场景获取 jti。
|
||||||
|
func ParseTokenRaw(tokenString string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
parser := jwt.NewParser(jwt.WithValidMethods([]string{AlgHS256, AlgRS256}), jwt.WithoutClaimsValidation())
|
||||||
|
if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuer 返回配置的签发者(未配置时为空)
|
||||||
|
func Issuer() string {
|
||||||
|
ensureKeys()
|
||||||
|
return issuerVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWK JSON Web Key(RS256 公钥)
|
||||||
|
type JWK struct {
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
Use string `json:"use"`
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
N string `json:"n"`
|
||||||
|
E string `json:"e"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWKS 返回 RSA 公钥集合(供 /auth/jwks.json 暴露)。
|
||||||
|
// 各应用本地用公钥验签即可,无需每次回调认证中心的 introspect 接口。
|
||||||
|
func JWKS() []JWK {
|
||||||
|
ensureKeys()
|
||||||
|
if rsaPub == nil || rsaPub.N == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []JWK{{
|
||||||
|
Kty: "RSA",
|
||||||
|
Use: "sig",
|
||||||
|
Alg: AlgRS256,
|
||||||
|
Kid: rsaKid,
|
||||||
|
N: base64.RawURLEncoding.EncodeToString(rsaPub.N.Bytes()),
|
||||||
|
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaPub.E)).Bytes()),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRSA 是否已配置 RS256 密钥对(决定认证中心能否签发非对称 token)
|
||||||
|
func HasRSA() bool {
|
||||||
|
ensureKeys()
|
||||||
|
return rsaPriv != nil && rsaPub != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kid 返回当前 RS256 密钥 ID
|
||||||
|
func Kid() string {
|
||||||
|
ensureKeys()
|
||||||
|
return rsaKid
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package jwtutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGenerateParseBackwardCompat 旧签发方式产生的 token 必须仍能被解析(向后兼容)
|
||||||
|
func TestGenerateParseBackwardCompat(t *testing.T) {
|
||||||
|
token, err := GenerateToken(1001, "zhangsan", 7, "backend")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateToken 失败: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := ParseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseToken 失败: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != 1001 || claims.Username != "zhangsan" || claims.TenantId != 7 || claims.UserType != "backend" {
|
||||||
|
t.Fatalf("claims 解析不符: %+v", claims)
|
||||||
|
}
|
||||||
|
// 旧 token 无认证中心扩展字段
|
||||||
|
if claims.Sid != "" || claims.ClientID != "" {
|
||||||
|
t.Fatal("旧 token 不应携带 sid/client_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSignTokenFallback 未配置 RSA 时自动回落 HS256,且可解析
|
||||||
|
func TestSignTokenFallback(t *testing.T) {
|
||||||
|
token, err := SignToken(TokenOptions{
|
||||||
|
Alg: AlgRS256, // 期望回落到 HS256
|
||||||
|
UserID: 42,
|
||||||
|
Username: "u",
|
||||||
|
TenantID: 3,
|
||||||
|
UserType: "tenant",
|
||||||
|
ClientID: "crm",
|
||||||
|
Sid: "sid-001",
|
||||||
|
Subject: "42",
|
||||||
|
TTL: 15 * time.Minute,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SignToken 失败: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := ParseToken(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseToken 失败: %v", err)
|
||||||
|
}
|
||||||
|
if claims.ClientID != "crm" || claims.Sid != "sid-001" || claims.Subject != "42" {
|
||||||
|
t.Fatalf("扩展 claims 解析不符: %+v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseInvalid 非法 token 必须报错
|
||||||
|
func TestParseInvalid(t *testing.T) {
|
||||||
|
if _, err := ParseToken("not-a-token"); err == nil {
|
||||||
|
t.Fatal("非法 token 应返回错误")
|
||||||
|
}
|
||||||
|
if _, err := ParseToken(""); err == nil {
|
||||||
|
t.Fatal("空 token 应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHasRSA 无配置时不应 panic
|
||||||
|
func TestHasRSA(t *testing.T) {
|
||||||
|
if HasRSA() {
|
||||||
|
t.Log("已配置 RSA 密钥对")
|
||||||
|
}
|
||||||
|
if Kid() == "" {
|
||||||
|
t.Fatal("kid 不应为空")
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
-55
@@ -1,55 +1,149 @@
|
|||||||
package passwordutil
|
package passwordutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"crypto/subtle"
|
||||||
"errors"
|
"encoding/base64"
|
||||||
"strings"
|
"encoding/hex"
|
||||||
)
|
"errors"
|
||||||
|
"fmt"
|
||||||
const (
|
"strings"
|
||||||
saltBytes = 16
|
|
||||||
separator = "$"
|
"golang.org/x/crypto/argon2"
|
||||||
hashLength = 64 // sha256 hex length
|
)
|
||||||
)
|
|
||||||
|
// 算法标识。
|
||||||
// Hash 生成 salt+hash 的存储串,格式:salt$hash(均为 hex)
|
// 历史数据用 legacy(sha256 单轮,无迭代拉伸,抗 GPU 爆破能力弱);
|
||||||
func Hash(plain string) (string, error) {
|
// 新密码统一用 argon2id,登录成功检测到 legacy 时应重新哈希升级。
|
||||||
plain = strings.TrimSpace(plain)
|
const (
|
||||||
if plain == "" {
|
AlgoArgon2id = "argon2id"
|
||||||
return "", errors.New("password 不能为空")
|
AlgoLegacy = "legacy"
|
||||||
}
|
)
|
||||||
salt := make([]byte, saltBytes)
|
|
||||||
if _, err := rand.Read(salt); err != nil {
|
// argon2id 参数(OWASP 推荐起步配置:64MB / 3 轮 / 并行 2)
|
||||||
return "", err
|
const (
|
||||||
}
|
argonTime = 3
|
||||||
saltHex := hex.EncodeToString(salt)
|
argonMemory = 64 * 1024 // 单位 KB,即 64MB
|
||||||
hashHex := hashHex(saltHex, plain)
|
argonThreads = 2
|
||||||
return saltHex + separator + hashHex, nil
|
argonKeyLen = 32
|
||||||
}
|
argonSaltLen = 16
|
||||||
|
)
|
||||||
// Verify 校验存储串(salt$hash)是否匹配输入明文密码。
|
|
||||||
func Verify(stored, plain string) bool {
|
// legacy 兼容参数
|
||||||
stored = strings.TrimSpace(stored)
|
const (
|
||||||
plain = strings.TrimSpace(plain)
|
saltBytes = 16
|
||||||
if stored == "" || plain == "" {
|
separator = "$"
|
||||||
return false
|
hashLength = 64 // sha256 hex length
|
||||||
}
|
)
|
||||||
parts := strings.Split(stored, separator)
|
|
||||||
if len(parts) != 2 {
|
// Hash 使用当前默认算法(argon2id)生成密码存储串。
|
||||||
return false
|
//
|
||||||
}
|
// 返回 PHC 标准格式(盐与参数内联,无需独立 salt 列):
|
||||||
saltHex := strings.TrimSpace(parts[0])
|
//
|
||||||
hashHexStored := strings.TrimSpace(parts[1])
|
// $argon2id$v=19$m=65536,t=3,p=2$<base64(salt)>$<base64(hash)>
|
||||||
if saltHex == "" || len(hashHexStored) != hashLength {
|
//
|
||||||
return false
|
// 注意:函数签名与旧版一致,全部调用点无需改动即可切换到新算法。
|
||||||
}
|
func Hash(plain string) (string, error) {
|
||||||
return hashHex(saltHex, plain) == strings.ToLower(hashHexStored)
|
plain = strings.TrimSpace(plain)
|
||||||
}
|
if plain == "" {
|
||||||
|
return "", errors.New("password 不能为空")
|
||||||
func hashHex(saltHex, plain string) string {
|
}
|
||||||
sum := sha256.Sum256([]byte(saltHex + plain))
|
salt := make([]byte, argonSaltLen)
|
||||||
return hex.EncodeToString(sum[:])
|
if _, err := rand.Read(salt); err != nil {
|
||||||
}
|
return "", err
|
||||||
|
}
|
||||||
|
key := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
argon2.Version,
|
||||||
|
argonMemory, argonTime, argonThreads,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(key),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 校验明文密码是否匹配存储串,自动识别算法:
|
||||||
|
// - $argon2id$... → argon2id(新)
|
||||||
|
// - <salt>$<hash> → legacy sha256(旧,兼容)
|
||||||
|
func Verify(stored, plain string) bool {
|
||||||
|
stored = strings.TrimSpace(stored)
|
||||||
|
plain = strings.TrimSpace(plain)
|
||||||
|
if stored == "" || plain == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(stored, "$argon2id$") {
|
||||||
|
return verifyArgon2id(stored, plain)
|
||||||
|
}
|
||||||
|
return verifyLegacy(stored, plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsRehash 判断已存储的密码是否需要用当前算法重新哈希。
|
||||||
|
// 登录成功后调用:返回 true 时应拿当次登录的明文重新 Hash 并落库,
|
||||||
|
// 实现旧算法用户「首次登录自动升级」,无需强制全员重置密码。
|
||||||
|
func NeedsRehash(stored string) bool {
|
||||||
|
return !strings.HasPrefix(strings.TrimSpace(stored), "$argon2id$")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlgoOf 返回存储串使用的算法标识,用于审计/统计旧算法存量。
|
||||||
|
func AlgoOf(stored string) string {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(stored), "$argon2id$") {
|
||||||
|
return AlgoArgon2id
|
||||||
|
}
|
||||||
|
return AlgoLegacy
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyArgon2id 解析 PHC 串并用相同参数重算比对(恒定时间比较)。
|
||||||
|
func verifyArgon2id(stored, plain string) bool {
|
||||||
|
parts := strings.Split(stored, "$")
|
||||||
|
// ["", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash]
|
||||||
|
if len(parts) != 6 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var memory, iterations, parallelism uint32
|
||||||
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if memory == 0 || iterations == 0 || parallelism == 0 || parallelism > 255 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
threads := uint8(parallelism)
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil || len(salt) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil || len(want) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got := argon2.IDKey([]byte(plain), salt, iterations, memory, threads, uint32(len(want)))
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyLegacy 校验旧格式:salt$hash(均为 hex),sha256(salt+plain)。
|
||||||
|
// 仅用于兼容历史数据,不再用于新密码。
|
||||||
|
func verifyLegacy(stored, plain string) bool {
|
||||||
|
stored = strings.TrimSpace(stored)
|
||||||
|
plain = strings.TrimSpace(plain)
|
||||||
|
if stored == "" || plain == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(stored, separator)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
saltHex := strings.TrimSpace(parts[0])
|
||||||
|
hashHexStored := strings.TrimSpace(parts[1])
|
||||||
|
if saltHex == "" || len(hashHexStored) != hashLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(saltHex); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return hashHex(saltHex, plain) == strings.ToLower(hashHexStored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashHex 旧算法:sha256(saltHex + plain),供 verifyLegacy 使用。
|
||||||
|
func hashHex(saltHex, plain string) string {
|
||||||
|
sum := sha256.Sum256([]byte(saltHex + plain))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package passwordutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHashVerifyArgon2id 新算法:哈希后可校验,错误密码拒绝
|
||||||
|
func TestHashVerifyArgon2id(t *testing.T) {
|
||||||
|
stored, err := Hash("Passw0rd@2026")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Hash 失败: %v", err)
|
||||||
|
}
|
||||||
|
if !verifyArgon2id(stored, "Passw0rd@2026") {
|
||||||
|
t.Fatal("正确密码应校验通过")
|
||||||
|
}
|
||||||
|
if verifyArgon2id(stored, "wrong-password") {
|
||||||
|
t.Fatal("错误密码不应通过")
|
||||||
|
}
|
||||||
|
if NeedsRehash(stored) {
|
||||||
|
t.Fatal("argon2id 不应需要重新哈希")
|
||||||
|
}
|
||||||
|
if AlgoOf(stored) != AlgoArgon2id {
|
||||||
|
t.Fatalf("算法标识应为 argon2id,实际 %s", AlgoOf(stored))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLegacyCompat 旧算法兼容:历史 salt$sha256 串仍可登录,且被标记为需要升级
|
||||||
|
func TestLegacyCompat(t *testing.T) {
|
||||||
|
saltHex := "0123456789abcdef0123456789abcdef"
|
||||||
|
sum := sha256.Sum256([]byte(saltHex + "oldpass"))
|
||||||
|
legacy := saltHex + "$" + hex.EncodeToString(sum[:])
|
||||||
|
|
||||||
|
if !Verify(legacy, "oldpass") {
|
||||||
|
t.Fatal("历史密码应校验通过(兼容)")
|
||||||
|
}
|
||||||
|
if Verify(legacy, "other") {
|
||||||
|
t.Fatal("错误密码不应通过")
|
||||||
|
}
|
||||||
|
if !NeedsRehash(legacy) {
|
||||||
|
t.Fatal("历史密码应标记为需要重新哈希")
|
||||||
|
}
|
||||||
|
if AlgoOf(legacy) != AlgoLegacy {
|
||||||
|
t.Fatalf("算法标识应为 legacy,实际 %s", AlgoOf(legacy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDifferentSaltSamePassword 相同明文两次哈希结果必须不同(盐随机)
|
||||||
|
func TestDifferentSaltSamePassword(t *testing.T) {
|
||||||
|
a, _ := Hash("same-password")
|
||||||
|
b, _ := Hash("same-password")
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("相同明文两次哈希不应相同(盐必须随机)")
|
||||||
|
}
|
||||||
|
if !Verify(a, "same-password") || !Verify(b, "same-password") {
|
||||||
|
t.Fatal("两份哈希都应能通过校验")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEmptyPassword 空密码边界
|
||||||
|
func TestEmptyPassword(t *testing.T) {
|
||||||
|
if _, err := Hash(" "); err == nil {
|
||||||
|
t.Fatal("空密码应返回错误")
|
||||||
|
}
|
||||||
|
if Verify("", "x") || Verify("x", "") {
|
||||||
|
t.Fatal("空输入不应通过校验")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ import (
|
|||||||
|
|
||||||
// Register 注册移动端 / 开放 API(api)路由。
|
// Register 注册移动端 / 开放 API(api)路由。
|
||||||
func Register() {
|
func Register() {
|
||||||
|
// 平台更新通知列表(无需登录)
|
||||||
|
beego.Router("/platform/api/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List")
|
||||||
|
|
||||||
|
// 平台更新通知详情(无需登录)
|
||||||
|
beego.Router("/platform/api/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail")
|
||||||
|
|
||||||
// 客户端检查更新(无需登录)
|
// 客户端检查更新(无需登录)
|
||||||
beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check")
|
beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Package auth 统一认证中心(UAC)路由:api.yunzer.cn/auth
|
||||||
|
//
|
||||||
|
// 重要:该路由组必须在 router.go 的所有运行模式下都注册
|
||||||
|
// (platform / backend / index / api / app / all),
|
||||||
|
// 否则对应模式下认证中心不可用。
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
authctl "server/controllers/auth"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register 注册认证中心路由
|
||||||
|
func Register() {
|
||||||
|
// ---- OIDC 标准端点 ----
|
||||||
|
beego.Router("/auth/.well-known/openid-configuration", &authctl.AuthOidcController{}, "get:Discovery")
|
||||||
|
beego.Router("/auth/jwks.json", &authctl.AuthOidcController{}, "get:JWKS")
|
||||||
|
beego.Router("/auth/authorize", &authctl.AuthOidcController{}, "get:Authorize")
|
||||||
|
beego.Router("/auth/token", &authctl.AuthOidcController{}, "post:Token")
|
||||||
|
beego.Router("/auth/userinfo", &authctl.AuthOidcController{}, "get:UserInfo;post:UserInfo")
|
||||||
|
beego.Router("/auth/introspect", &authctl.AuthOidcController{}, "post:Introspect")
|
||||||
|
beego.Router("/auth/revoke", &authctl.AuthOidcController{}, "post:Revoke")
|
||||||
|
|
||||||
|
// ---- 登录与会话 ----
|
||||||
|
// 注意:beego 同一路径重复 Router 会覆盖,故 /auth/logout 只注册一次
|
||||||
|
beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit")
|
||||||
|
beego.Router("/auth/logout", &authctl.AuthLoginController{}, "get:LogoutPage;post:LogoutAction")
|
||||||
|
beego.Router("/auth/tenants", &authctl.AuthLoginController{}, "get:Tenants")
|
||||||
|
beego.Router("/auth/switch-tenant", &authctl.AuthLoginController{}, "post:SwitchTenant")
|
||||||
|
beego.Router("/auth/sessions", &authctl.AuthLoginController{}, "get:Sessions")
|
||||||
|
beego.Router("/auth/sessions/kick", &authctl.AuthLoginController{}, "post:KickSession")
|
||||||
|
beego.Router("/auth/verify-config", &authctl.AuthLoginController{}, "get:VerifyConfig")
|
||||||
|
}
|
||||||
+90
-80
@@ -1,80 +1,90 @@
|
|||||||
package routers
|
package routers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"server/controllers"
|
"server/controllers"
|
||||||
"server/middleware"
|
"server/middleware"
|
||||||
"server/routers/api"
|
"server/routers/api"
|
||||||
"server/routers/app"
|
"server/routers/app"
|
||||||
"server/routers/backend"
|
authrouter "server/routers/auth"
|
||||||
"server/routers/index"
|
"server/routers/backend"
|
||||||
"server/routers/platform"
|
"server/routers/index"
|
||||||
|
"server/routers/platform"
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
|
||||||
"github.com/beego/beego/v2/server/web/context"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
)
|
"github.com/beego/beego/v2/server/web/context"
|
||||||
|
)
|
||||||
// 初始化路由(精简版)
|
|
||||||
func init() {
|
// 初始化路由(精简版)
|
||||||
// 全局 CORS 处理 + 预检请求
|
func init() {
|
||||||
// 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。
|
// 全局 CORS 处理 + 预检请求
|
||||||
// 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。
|
// 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。
|
||||||
beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) {
|
// 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。
|
||||||
ctx.Output.Header("Access-Control-Allow-Origin", "*")
|
beego.InsertFilter("*", beego.BeforeRouter, func(ctx *context.Context) {
|
||||||
ctx.Output.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
ctx.Output.Header("Access-Control-Allow-Origin", "*")
|
||||||
ctx.Output.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
|
ctx.Output.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
|
||||||
ctx.Output.Header("Access-Control-Max-Age", "86400")
|
ctx.Output.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization")
|
||||||
|
ctx.Output.Header("Access-Control-Max-Age", "86400")
|
||||||
if ctx.Input.Method() == "OPTIONS" {
|
|
||||||
ctx.Output.Status = 200
|
if ctx.Input.Method() == "OPTIONS" {
|
||||||
ctx.Output.Body([]byte(""))
|
ctx.Output.Status = 200
|
||||||
return
|
ctx.Output.Body([]byte(""))
|
||||||
}
|
return
|
||||||
})
|
}
|
||||||
|
})
|
||||||
// 全局操作日志:请求开始采集并异步写入
|
|
||||||
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
|
// 全局 JWT 鉴权:解析 token 并写入上下文(userId/tenantId/userType/...)。
|
||||||
|
// 模式由 app.conf 的 auth_enforce 控制:
|
||||||
// Let's Encrypt HTTP-01 验证回访。
|
// warn(默认,观察模式,只记录不拦截)→ on(真正拦截)→ off(紧急关闭)
|
||||||
// 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。
|
// 放在操作日志之前注册,保证操作日志能取到 userId。
|
||||||
beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve")
|
beego.InsertFilter("*", beego.BeforeRouter, middleware.JWTAuthMiddleware())
|
||||||
|
|
||||||
// 根据运行模式选择要注册的路由组
|
// 全局操作日志:请求开始采集并异步写入
|
||||||
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
|
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
|
||||||
mode := os.Getenv("APP_MODE")
|
|
||||||
if mode == "" {
|
// Let's Encrypt HTTP-01 验证回访。
|
||||||
mode, _ = beego.AppConfig.String("app_mode")
|
// 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。
|
||||||
}
|
beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve")
|
||||||
if mode == "" {
|
|
||||||
mode = "all"
|
// 统一认证中心(UAC):所有运行模式都必须注册,否则对应模式下无法登录
|
||||||
}
|
authrouter.Register()
|
||||||
|
|
||||||
switch mode {
|
// 根据运行模式选择要注册的路由组
|
||||||
case "platform":
|
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
|
||||||
platform.Register()
|
mode := os.Getenv("APP_MODE")
|
||||||
app.Register()
|
if mode == "" {
|
||||||
// 在 platform 模式下,仍保留 backend 登录相关路由,避免后台登录 404
|
mode, _ = beego.AppConfig.String("app_mode")
|
||||||
backend.RegisterAuthRoutes()
|
}
|
||||||
case "backend":
|
if mode == "" {
|
||||||
backend.Register()
|
mode = "all"
|
||||||
app.Register()
|
}
|
||||||
case "index":
|
|
||||||
index.Register()
|
switch mode {
|
||||||
app.Register()
|
case "platform":
|
||||||
case "api":
|
platform.Register()
|
||||||
api.Register()
|
app.Register()
|
||||||
case "app":
|
// 在 platform 模式下,仍保留 backend 登录相关路由,避免后台登录 404
|
||||||
app.Register()
|
backend.RegisterAuthRoutes()
|
||||||
case "all":
|
case "backend":
|
||||||
platform.Register()
|
backend.Register()
|
||||||
backend.Register()
|
app.Register()
|
||||||
index.Register()
|
case "index":
|
||||||
api.Register()
|
index.Register()
|
||||||
app.Register()
|
app.Register()
|
||||||
default:
|
case "api":
|
||||||
// 未知模式时,退回到只启用平台端,避免启动失败
|
api.Register()
|
||||||
platform.Register()
|
case "app":
|
||||||
app.Register()
|
app.Register()
|
||||||
}
|
case "all":
|
||||||
}
|
platform.Register()
|
||||||
|
backend.Register()
|
||||||
|
index.Register()
|
||||||
|
api.Register()
|
||||||
|
app.Register()
|
||||||
|
default:
|
||||||
|
// 未知模式时,退回到只启用平台端,避免启动失败
|
||||||
|
platform.Register()
|
||||||
|
app.Register()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
// 统一认证中心一次性数据迁移:
|
||||||
|
// yz_system_tenant_user → yz_auth_identity(自然人)+ yz_auth_tenant_user(身份-企业绑定)
|
||||||
|
//
|
||||||
|
// 前置条件:
|
||||||
|
// 1. 已人工执行 docs/sql/create_auth_tables.sql 建表;
|
||||||
|
// 2. 在 go/ 目录下运行(需读取 conf/app.conf)。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
// go run scripts/migrate_auth.go -check 预检:只输出统计与冲突报告,不写任何数据
|
||||||
|
// go run scripts/migrate_auth.go -apply 执行迁移(自动备份源表后再写入)
|
||||||
|
// go run scripts/migrate_auth.go -apply -force 目标表已有数据时强制重跑
|
||||||
|
//
|
||||||
|
// 迁移规则:
|
||||||
|
// - 归并键优先级:手机号 > 邮箱 > 租户内账号;三者皆空时按 tid+原 uid 兜底;
|
||||||
|
// - 同一归并键命中多条(多企业任职)→ 只建 1 个 identity,再建 N 条企业绑定;
|
||||||
|
// - 同一归并键下密码不一致 → 记为冲突,取 is_default 优先、其次 id 最大的那条,
|
||||||
|
// 并写入冲突报告供人工核对;
|
||||||
|
// - 历史密码为 legacy(salt$sha256),无法离线转 argon2id,原样搬迁,
|
||||||
|
// 用户登录成功时自动重新哈希升级。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/passwordutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// srcRow 源表行(yz_system_tenant_user)
|
||||||
|
type srcRow struct {
|
||||||
|
ID uint64
|
||||||
|
Tid uint64
|
||||||
|
Uid uint64
|
||||||
|
Account string
|
||||||
|
Name string
|
||||||
|
Phone string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
GroupID uint64
|
||||||
|
OrgID uint64
|
||||||
|
IsDefault int8
|
||||||
|
Status int8
|
||||||
|
}
|
||||||
|
|
||||||
|
// group 同一归并键下的多条源记录
|
||||||
|
type group struct {
|
||||||
|
key string
|
||||||
|
rows []srcRow
|
||||||
|
conflict bool // 同键下密码不一致
|
||||||
|
pwdValues int
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimPtr(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeKey 归并键:手机 > 邮箱 > 账号;都为空时返回空串(由调用方兜底)
|
||||||
|
func mergeKey(r srcRow) string {
|
||||||
|
if r.Phone != "" {
|
||||||
|
return "m:" + r.Phone
|
||||||
|
}
|
||||||
|
if r.Email != "" {
|
||||||
|
return "e:" + r.Email
|
||||||
|
}
|
||||||
|
if r.Account != "" {
|
||||||
|
return "a:" + r.Account
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
check := flag.Bool("check", false, "预检模式,只输出报告不写数据")
|
||||||
|
apply := flag.Bool("apply", false, "执行迁移")
|
||||||
|
force := flag.Bool("force", false, "目标表已有数据时强制重跑")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if !*check && !*apply {
|
||||||
|
fmt.Println("请指定 -check(预检)或 -apply(执行迁移)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := beego.LoadAppConfig("ini", "conf/app.conf"); err != nil {
|
||||||
|
log.Printf("加载 conf/app.conf 失败(若已自动加载可忽略): %v", err)
|
||||||
|
}
|
||||||
|
models.Init("")
|
||||||
|
|
||||||
|
rows, err := loadSourceRows()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("读取源表失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
log.Fatal("源表 yz_system_tenant_user 无有效数据,无需迁移")
|
||||||
|
}
|
||||||
|
log.Printf("源表有效记录: %d 条", len(rows))
|
||||||
|
|
||||||
|
groups := buildGroups(rows)
|
||||||
|
log.Printf("归并后身份数: %d 个(其中冲突 %d 组)", len(groups), countConflict(groups))
|
||||||
|
|
||||||
|
// 冲突报告
|
||||||
|
if err := writeConflictReport(groups); err != nil {
|
||||||
|
log.Printf("写入冲突报告失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *check {
|
||||||
|
printSummary(rows, groups)
|
||||||
|
log.Println("预检完成(未写入任何数据)。确认无误后执行: go run scripts/migrate_auth.go -apply")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 执行迁移 ----
|
||||||
|
if err := ensureTargetWritable(*force); err != nil {
|
||||||
|
log.Fatalf("目标表检查失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := backupSourceTable(); err != nil {
|
||||||
|
log.Fatalf("备份源表失败(已中止迁移): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migratedBindings := 0
|
||||||
|
conflictHandled := 0
|
||||||
|
mapFile, err := os.Create(fmt.Sprintf("migrate_auth_map_%s.csv", time.Now().Format("20060102150405")))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("创建映射文件失败: %v", err)
|
||||||
|
}
|
||||||
|
defer mapFile.Close()
|
||||||
|
mapWriter := csv.NewWriter(mapFile)
|
||||||
|
defer mapWriter.Flush()
|
||||||
|
_ = mapWriter.Write([]string{"old_id", "old_uid", "tid", "identity_id", "tenant_user_id", "account", "merge_key", "conflict"})
|
||||||
|
|
||||||
|
for _, g := range groups {
|
||||||
|
// 代表行:is_default 优先,其次 id 最大
|
||||||
|
rep := pickRepresentative(g)
|
||||||
|
identityID, err := insertIdentity(rep, g.key)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("写入身份失败(key=%s): %v", g.key, err)
|
||||||
|
}
|
||||||
|
if g.conflict {
|
||||||
|
conflictHandled++
|
||||||
|
}
|
||||||
|
for _, r := range g.rows {
|
||||||
|
tu := models.AuthTenantUser{
|
||||||
|
Tid: r.Tid,
|
||||||
|
IdentityID: identityID,
|
||||||
|
GroupID: r.GroupID,
|
||||||
|
OrgID: r.OrgID,
|
||||||
|
IsDefault: r.IsDefault,
|
||||||
|
Status: r.Status,
|
||||||
|
}
|
||||||
|
if r.Account != "" {
|
||||||
|
tu.Account = &r.Account
|
||||||
|
}
|
||||||
|
if r.Name != "" {
|
||||||
|
tu.Name = &r.Name
|
||||||
|
}
|
||||||
|
if r.Phone != "" {
|
||||||
|
tu.Phone = &r.Phone
|
||||||
|
}
|
||||||
|
if r.Email != "" {
|
||||||
|
tu.Email = &r.Email
|
||||||
|
}
|
||||||
|
newID, err := models.Orm.Insert(&tu)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("写入企业绑定失败(tid=%d, identity=%d): %v", r.Tid, identityID, err)
|
||||||
|
}
|
||||||
|
migratedBindings++
|
||||||
|
_ = mapWriter.Write([]string{
|
||||||
|
fmt.Sprintf("%d", r.ID), fmt.Sprintf("%d", r.Uid),
|
||||||
|
fmt.Sprintf("%d", r.Tid), fmt.Sprintf("%d", identityID),
|
||||||
|
fmt.Sprintf("%d", newID), r.Account, g.key,
|
||||||
|
fmt.Sprintf("%v", g.conflict),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("迁移完成:身份 %d 个,企业绑定 %d 条,处理冲突 %d 组", len(groups), migratedBindings, conflictHandled)
|
||||||
|
log.Printf("映射文件已生成:%s(保存好,回滚或排查用)", mapFile.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSourceRows 读取未软删的源记录
|
||||||
|
func loadSourceRows() ([]srcRow, error) {
|
||||||
|
var list []models.SystemTenantUser
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
All(&list)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows := make([]srcRow, 0, len(list))
|
||||||
|
for _, m := range list {
|
||||||
|
rows = append(rows, srcRow{
|
||||||
|
ID: m.ID,
|
||||||
|
Tid: m.Tid,
|
||||||
|
Uid: m.Uid,
|
||||||
|
Account: trimPtr(m.Account),
|
||||||
|
Name: trimPtr(m.Name),
|
||||||
|
Phone: trimPtr(m.Phone),
|
||||||
|
Email: trimPtr(m.Email),
|
||||||
|
Password: trimPtr(m.Password),
|
||||||
|
GroupID: m.GroupID,
|
||||||
|
OrgID: m.OrgID,
|
||||||
|
IsDefault: m.IsDefault,
|
||||||
|
Status: m.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGroups 按归并键分组,并标记密码冲突
|
||||||
|
func buildGroups(rows []srcRow) []*group {
|
||||||
|
buckets := map[string]*group{}
|
||||||
|
order := make([]string, 0)
|
||||||
|
for _, r := range rows {
|
||||||
|
key := mergeKey(r)
|
||||||
|
if key == "" {
|
||||||
|
// 手机/邮箱/账号全空:无法归并,按 tid+uid 兜底为独立身份
|
||||||
|
key = fmt.Sprintf("u:%d-%d", r.Tid, r.Uid)
|
||||||
|
}
|
||||||
|
g, ok := buckets[key]
|
||||||
|
if !ok {
|
||||||
|
g = &group{key: key}
|
||||||
|
buckets[key] = g
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
g.rows = append(g.rows, r)
|
||||||
|
}
|
||||||
|
sort.Strings(order)
|
||||||
|
out := make([]*group, 0, len(order))
|
||||||
|
for _, k := range order {
|
||||||
|
g := buckets[k]
|
||||||
|
pwdSet := map[string]bool{}
|
||||||
|
for _, r := range g.rows {
|
||||||
|
pwdSet[r.Password] = true
|
||||||
|
}
|
||||||
|
g.pwdValues = len(pwdSet)
|
||||||
|
g.conflict = len(pwdSet) > 1
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickRepresentative 选出代表行:is_default 优先,其次 id 最大
|
||||||
|
func pickRepresentative(g *group) srcRow {
|
||||||
|
sorted := make([]srcRow, len(g.rows))
|
||||||
|
copy(sorted, g.rows)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool {
|
||||||
|
if sorted[i].IsDefault != sorted[j].IsDefault {
|
||||||
|
return sorted[i].IsDefault > sorted[j].IsDefault
|
||||||
|
}
|
||||||
|
return sorted[i].ID > sorted[j].ID
|
||||||
|
})
|
||||||
|
return sorted[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func countConflict(groups []*group) int {
|
||||||
|
n := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.conflict {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertIdentity 写入身份;密码算法按原值识别(legacy 保持原样,首登自动升级)
|
||||||
|
func insertIdentity(rep srcRow, key string) (uint64, error) {
|
||||||
|
algo := passwordutil.AlgoLegacy
|
||||||
|
if passwordutil.AlgoOf(rep.Password) == passwordutil.AlgoArgon2id {
|
||||||
|
algo = passwordutil.AlgoArgon2id
|
||||||
|
}
|
||||||
|
identity := models.AuthIdentity{
|
||||||
|
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||||
|
PasswordAlgo: algo,
|
||||||
|
Status: rep.Status,
|
||||||
|
}
|
||||||
|
if rep.Phone != "" {
|
||||||
|
identity.Mobile = &rep.Phone
|
||||||
|
}
|
||||||
|
if rep.Email != "" {
|
||||||
|
identity.Email = &rep.Email
|
||||||
|
}
|
||||||
|
if rep.Password != "" {
|
||||||
|
identity.PasswordHash = &rep.Password
|
||||||
|
}
|
||||||
|
if rep.Name != "" {
|
||||||
|
identity.Nickname = &rep.Name
|
||||||
|
}
|
||||||
|
id, err := models.Orm.Insert(&identity)
|
||||||
|
return uint64(id), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureTargetWritable 目标表已有数据时需 -force 才继续,避免重复迁移
|
||||||
|
func ensureTargetWritable(force bool) error {
|
||||||
|
cnt, err := models.Orm.QueryTable(new(models.AuthTenantUser)).Count()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cnt > 0 && !force {
|
||||||
|
return fmt.Errorf("yz_auth_tenant_user 已存在 %d 条数据,如确认要重跑请加 -force(建议先清空目标表)", cnt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// backupSourceTable 备份源表
|
||||||
|
func backupSourceTable() error {
|
||||||
|
name := fmt.Sprintf("yz_system_tenant_user_bak_%s", time.Now().Format("20060102150405"))
|
||||||
|
_, err := models.Orm.Raw(fmt.Sprintf("CREATE TABLE `%s` AS SELECT * FROM `yz_system_tenant_user`", name)).Exec()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("源表已备份为:%s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConflictReport 输出冲突报告 CSV
|
||||||
|
func writeConflictReport(groups []*group) error {
|
||||||
|
conflicts := make([]*group, 0)
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.conflict {
|
||||||
|
conflicts = append(conflicts, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 无法归并(手机/邮箱/账号全空)的记录也提示
|
||||||
|
unkeyed := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
if strings.HasPrefix(g.key, "u:") {
|
||||||
|
unkeyed += len(g.rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("migrate_auth_conflict_%s.csv", time.Now().Format("20060102150405"))
|
||||||
|
f, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w := csv.NewWriter(f)
|
||||||
|
defer w.Flush()
|
||||||
|
_ = w.Write([]string{"merge_key", "row_count", "password_variants", "tids", "representative_id"})
|
||||||
|
for _, g := range conflicts {
|
||||||
|
tids := make([]string, 0, len(g.rows))
|
||||||
|
for _, r := range g.rows {
|
||||||
|
tids = append(tids, fmt.Sprintf("%d", r.Tid))
|
||||||
|
}
|
||||||
|
rep := pickRepresentative(g)
|
||||||
|
_ = w.Write([]string{
|
||||||
|
g.key,
|
||||||
|
fmt.Sprintf("%d", len(g.rows)),
|
||||||
|
fmt.Sprintf("%d", g.pwdValues),
|
||||||
|
strings.Join(tids, "|"),
|
||||||
|
fmt.Sprintf("%d", rep.ID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(conflicts) > 0 {
|
||||||
|
log.Printf("发现密码冲突 %d 组,详见 %s(迁移取 is_default 优先的那条,请人工核对)", len(conflicts), filename)
|
||||||
|
}
|
||||||
|
if unkeyed > 0 {
|
||||||
|
log.Printf("警告:有 %d 条记录手机/邮箱/账号全空,已按 tid+uid 各自独立建身份,建议补齐手机号", unkeyed)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSummary 预检模式输出摘要
|
||||||
|
func printSummary(rows []srcRow, groups []*group) {
|
||||||
|
tenantSet := map[uint64]bool{}
|
||||||
|
for _, r := range rows {
|
||||||
|
tenantSet[r.Tid] = true
|
||||||
|
}
|
||||||
|
multi := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
if len(g.rows) > 1 {
|
||||||
|
multi++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Println("---------------- 预检摘要 ----------------")
|
||||||
|
log.Printf("源记录数 : %d", len(rows))
|
||||||
|
log.Printf("涉及企业数 : %d", len(tenantSet))
|
||||||
|
log.Printf("归并后身份数 : %d", len(groups))
|
||||||
|
log.Printf("多企业任职身份 : %d", multi)
|
||||||
|
log.Printf("密码冲突组数 : %d", countConflict(groups))
|
||||||
|
log.Println("------------------------------------------")
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Package auth 统一认证中心(UAC)业务层。
|
||||||
|
//
|
||||||
|
// 认证中心只负责「你是谁、你能进哪些企业、你能用哪些应用」,
|
||||||
|
// 菜单/按钮/数据权限仍由各业务端自治。
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantOption 登录后可选择进入的企业(一人可在多家企业任职)
|
||||||
|
type TenantOption struct {
|
||||||
|
Tid uint64 `json:"tid"`
|
||||||
|
TenantName string `json:"tenant_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
GroupID uint64 `json:"group_id"`
|
||||||
|
OrgID uint64 `json:"org_id"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentityProfile 认证中心视角的身份概要
|
||||||
|
type IdentityProfile struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
UnionID string `json:"union_id"`
|
||||||
|
Mobile string `json:"mobile"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Status int8 `json:"status"`
|
||||||
|
MfaOn bool `json:"mfa_enabled"`
|
||||||
|
Tenants []TenantOption `json:"tenants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindIdentityByAccount 按登录账号查找身份。
|
||||||
|
// 匹配顺序:手机号 → 邮箱 → 企业内账号(先查绑定再回查身份)。
|
||||||
|
func FindIdentityByAccount(account string) (*models.AuthIdentity, error) {
|
||||||
|
account = strings.TrimSpace(account)
|
||||||
|
if account == "" {
|
||||||
|
return nil, orm.ErrNoRows
|
||||||
|
}
|
||||||
|
base := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("delete_time__isnull", true)
|
||||||
|
|
||||||
|
for _, field := range []string{"mobile", "email"} {
|
||||||
|
m := &models.AuthIdentity{}
|
||||||
|
if err := base.Filter(field, account).One(m); err == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业内账号:yz_auth_tenant_user.account → identity_id
|
||||||
|
var bind models.AuthTenantUser
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||||
|
Filter("account", account).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
OrderBy("-is_default", "id").
|
||||||
|
One(&bind); err == nil {
|
||||||
|
m := &models.AuthIdentity{}
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", bind.IdentityID).One(m); err == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, orm.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTenantOptions 列出该身份可登录的企业(过滤企业停用与绑定停用)。
|
||||||
|
func ListTenantOptions(identityID uint64) ([]TenantOption, error) {
|
||||||
|
var binds []models.AuthTenantUser
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||||
|
Filter("identity_id", identityID).
|
||||||
|
Filter("status", 1).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
OrderBy("-is_default", "id").
|
||||||
|
All(&binds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]TenantOption, 0, len(binds))
|
||||||
|
for _, b := range binds {
|
||||||
|
var tenant models.SystemTenant
|
||||||
|
if err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||||
|
Filter("id", b.Tid).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&tenant); err != nil {
|
||||||
|
continue // 租户不存在或已删除
|
||||||
|
}
|
||||||
|
if tenant.Status != 1 {
|
||||||
|
continue // 租户停用
|
||||||
|
}
|
||||||
|
opt := TenantOption{
|
||||||
|
Tid: b.Tid,
|
||||||
|
TenantName: strings.TrimSpace(tenant.TenantName),
|
||||||
|
GroupID: b.GroupID,
|
||||||
|
OrgID: b.OrgID,
|
||||||
|
IsDefault: b.IsDefault == 1,
|
||||||
|
}
|
||||||
|
if b.Name != nil {
|
||||||
|
opt.Name = strings.TrimSpace(*b.Name)
|
||||||
|
}
|
||||||
|
out = append(out, opt)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantUser 获取身份在指定企业的绑定关系
|
||||||
|
func GetTenantUser(identityID, tid uint64) (*models.AuthTenantUser, error) {
|
||||||
|
var bind models.AuthTenantUser
|
||||||
|
err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||||
|
Filter("identity_id", identityID).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&bind)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &bind, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildProfile 组装身份概要(含可选企业列表)
|
||||||
|
func BuildProfile(m *models.AuthIdentity) (*IdentityProfile, error) {
|
||||||
|
tenants, err := ListTenantOptions(m.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := &IdentityProfile{
|
||||||
|
ID: m.ID,
|
||||||
|
UnionID: m.UnionID,
|
||||||
|
Status: m.Status,
|
||||||
|
MfaOn: m.MfaEnabled == 1,
|
||||||
|
Tenants: tenants,
|
||||||
|
}
|
||||||
|
if m.Mobile != nil {
|
||||||
|
p.Mobile = *m.Mobile
|
||||||
|
}
|
||||||
|
if m.Email != nil {
|
||||||
|
p.Email = *m.Email
|
||||||
|
}
|
||||||
|
if m.Nickname != nil {
|
||||||
|
p.Nickname = *m.Nickname
|
||||||
|
}
|
||||||
|
if m.Avatar != nil {
|
||||||
|
p.Avatar = *m.Avatar
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/passwordutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 登录方式与认证手段(amr)
|
||||||
|
const (
|
||||||
|
LoginTypePassword = "password"
|
||||||
|
LoginTypeSMS = "sms"
|
||||||
|
LoginTypeThird = "third"
|
||||||
|
|
||||||
|
AmrPwd = "pwd"
|
||||||
|
AmrSms = "sms"
|
||||||
|
AmrOtp = "otp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 失败锁定策略
|
||||||
|
const (
|
||||||
|
MaxFailCount = 5 // 连续失败次数阈值
|
||||||
|
LockDuration = 15 * time.Minute // 锁定时长
|
||||||
|
PendingTenantID = 0 // 未选择企业时的 tid
|
||||||
|
)
|
||||||
|
|
||||||
|
// 登录失败错误
|
||||||
|
var (
|
||||||
|
ErrAccountDisabled = errors.New("账号已禁用")
|
||||||
|
ErrAccountLocked = errors.New("账号已被锁定,请稍后再试")
|
||||||
|
ErrPasswordWrong = errors.New("账号或密码错误")
|
||||||
|
ErrNoTenantBound = errors.New("该账号未绑定任何企业")
|
||||||
|
ErrTenantNotAllowed = errors.New("无权访问该企业")
|
||||||
|
ErrTenantUserDisabled = errors.New("在该企业的账号已被停用")
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginResult 登录结果。
|
||||||
|
// 当 NeedChooseTenant 为 true 时表示一人多企业,前端需展示企业列表让用户选择,
|
||||||
|
// 选择后调用 ChooseTenant 换取正式令牌。
|
||||||
|
type LoginResult struct {
|
||||||
|
NeedChooseTenant bool `json:"need_choose_tenant"`
|
||||||
|
Identity *IdentityProfile `json:"identity"`
|
||||||
|
Tenants []TenantOption `json:"tenants"`
|
||||||
|
Tokens *TokenPair `json:"tokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordLogin 账号密码登录
|
||||||
|
//
|
||||||
|
// 流程:查身份 → 校验状态/锁定 → 校验密码 → 失败计数 → 列出可登录企业
|
||||||
|
// - 只绑定 1 家企业:直接建会话并签发令牌
|
||||||
|
// - 绑定多家企业:建待选会话(tid=0),签发临时令牌,等用户选择企业
|
||||||
|
func PasswordLogin(account, password, clientID, ip, userAgent string) (*LoginResult, error) {
|
||||||
|
identity, err := FindIdentityByAccount(account)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrPasswordWrong // 不暴露账号是否存在
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkIdentityStatus(identity); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if identity.PasswordHash == nil || !passwordutil.Verify(*identity.PasswordHash, password) {
|
||||||
|
_ = recordFail(identity.ID)
|
||||||
|
return nil, ErrPasswordWrong
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功:重置失败计数,旧算法密码自动升级
|
||||||
|
_ = resetFail(identity.ID)
|
||||||
|
if passwordutil.NeedsRehash(*identity.PasswordHash) {
|
||||||
|
if hashed, hErr := passwordutil.Hash(password); hErr == nil {
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", identity.ID).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"password_hash": hashed,
|
||||||
|
"password_algo": passwordutil.AlgoArgon2id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tenants, err := ListTenantOptions(identity.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tenants) == 0 {
|
||||||
|
_ = writeLoginLog(0, identity.ID, account, "", clientID, LoginTypePassword, AmrPwd, 0, ErrNoTenantBound.Error(), ip, userAgent)
|
||||||
|
return nil, ErrNoTenantBound
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := BuildProfile(identity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &LoginResult{Tenants: tenants, Identity: profile}
|
||||||
|
|
||||||
|
// 唯一企业直接进入;多家企业先建待选会话
|
||||||
|
targetTid := uint64(PendingTenantID)
|
||||||
|
if len(tenants) == 1 {
|
||||||
|
targetTid = tenants[0].Tid
|
||||||
|
}
|
||||||
|
sess, err := CreateSession(SessionInfo{
|
||||||
|
IdentityID: identity.ID,
|
||||||
|
Tid: targetTid,
|
||||||
|
ClientID: clientID,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
LoginType: LoginTypePassword,
|
||||||
|
Amr: AmrPwd,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 待选状态下令牌有效期缩短为 10 分钟(仅够用户做完选择)
|
||||||
|
accessTTL := 0
|
||||||
|
if targetTid == PendingTenantID {
|
||||||
|
accessTTL = 600
|
||||||
|
result.NeedChooseTenant = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := IssueTokens(TokenIssue{
|
||||||
|
IdentityID: identity.ID,
|
||||||
|
Tid: targetTid,
|
||||||
|
ClientID: clientID,
|
||||||
|
Sid: sess.Sid,
|
||||||
|
Username: profile.Nickname,
|
||||||
|
UserType: "tenant",
|
||||||
|
Amr: AmrPwd,
|
||||||
|
AccessTTL: accessTTL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Tokens = tokens
|
||||||
|
|
||||||
|
tenantName := ""
|
||||||
|
if targetTid != PendingTenantID {
|
||||||
|
for _, t := range tenants {
|
||||||
|
if t.Tid == targetTid {
|
||||||
|
tenantName = t.TenantName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = writeLoginLog(targetTid, identity.ID, account, profile.Nickname, clientID, LoginTypePassword, AmrPwd, 1, "登录成功", ip, userAgent)
|
||||||
|
_ = tenantName
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChooseTenant 选择进入的企业:校验绑定后更新会话 tid 并重新签发令牌
|
||||||
|
func ChooseTenant(sid string, tid uint64, clientID string) (*TokenPair, *IdentityProfile, error) {
|
||||||
|
sess, err := GetSession(sid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
bind, err := GetTenantUser(sess.IdentityID, tid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, ErrTenantNotAllowed
|
||||||
|
}
|
||||||
|
if bind.Status != 1 {
|
||||||
|
return nil, nil, ErrTenantUserDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity models.AuthIdentity
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", sess.IdentityID).One(&identity); err != nil {
|
||||||
|
return nil, nil, ErrPasswordWrong
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SwitchSessionTenant(sid, tid); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := BuildProfile(&identity)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
tokens, err := IssueTokens(TokenIssue{
|
||||||
|
IdentityID: identity.ID,
|
||||||
|
Tid: tid,
|
||||||
|
ClientID: clientID,
|
||||||
|
Sid: sid,
|
||||||
|
Username: profile.Nickname,
|
||||||
|
UserType: "tenant",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return tokens, profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkIdentityStatus 校验身份状态与锁定时间
|
||||||
|
func checkIdentityStatus(m *models.AuthIdentity) error {
|
||||||
|
if m.Status == models.AuthIdentityStatusDisabled {
|
||||||
|
return ErrAccountDisabled
|
||||||
|
}
|
||||||
|
if m.Status == models.AuthIdentityStatusLocked {
|
||||||
|
if m.LockedUntil != nil && m.LockedUntil.After(time.Now()) {
|
||||||
|
return ErrAccountLocked
|
||||||
|
}
|
||||||
|
// 锁定已到期,自动解锁
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", m.ID).
|
||||||
|
Update(map[string]interface{}{"status": models.AuthIdentityStatusEnabled, "fail_count": 0, "locked_until": nil})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordFail 记录一次失败,达到阈值则锁定
|
||||||
|
func recordFail(identityID uint64) error {
|
||||||
|
var m models.AuthIdentity
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", identityID).One(&m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fail := m.FailCount + 1
|
||||||
|
update := map[string]interface{}{"fail_count": fail}
|
||||||
|
if fail >= MaxFailCount {
|
||||||
|
lockUntil := time.Now().Add(LockDuration)
|
||||||
|
update["status"] = models.AuthIdentityStatusLocked
|
||||||
|
update["locked_until"] = lockUntil
|
||||||
|
}
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", identityID).Update(update)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetFail 登录成功后重置失败计数与锁定
|
||||||
|
func resetFail(identityID uint64) error {
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", identityID).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"fail_count": 0,
|
||||||
|
"locked_until": nil,
|
||||||
|
"status": models.AuthIdentityStatusEnabled,
|
||||||
|
"last_login_at": time.Now(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLoginLog 写入统一登录日志(失败不影响主流程)
|
||||||
|
func writeLoginLog(tid uint64, identityID uint64, account, userName, clientID, loginType, amr string, status int8, message, ip, userAgent string) error {
|
||||||
|
log := &models.AuthLoginLog{
|
||||||
|
IdentityID: &identityID,
|
||||||
|
Account: strings.TrimSpace(account),
|
||||||
|
UserName: userName,
|
||||||
|
ClientID: clientID,
|
||||||
|
LoginType: loginType,
|
||||||
|
Status: status,
|
||||||
|
Message: message,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
}
|
||||||
|
if tid > 0 {
|
||||||
|
log.Tid = &tid
|
||||||
|
}
|
||||||
|
if amr != "" {
|
||||||
|
log.Amr = &amr
|
||||||
|
}
|
||||||
|
_, err := models.Orm.Insert(log)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 登出:吊销刷新令牌、会话与访问令牌
|
||||||
|
func Logout(accessToken, refreshToken string) error {
|
||||||
|
return RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 默认会话配置(租户未配置 yz_auth_tenant_auth_config 时使用)
|
||||||
|
const (
|
||||||
|
DefaultSessionTTL = 7200 // 会话有效期(秒)
|
||||||
|
DefaultMaxSession = 1 // 默认 1 号 1 机
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantSessionPolicy 租户会话策略
|
||||||
|
type TenantSessionPolicy struct {
|
||||||
|
SessionTTL int
|
||||||
|
MaxSession int
|
||||||
|
KickStrategy int8
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTenantSessionPolicy 读取租户登录策略,未配置时返回默认值
|
||||||
|
func GetTenantSessionPolicy(tid uint64) TenantSessionPolicy {
|
||||||
|
policy := TenantSessionPolicy{
|
||||||
|
SessionTTL: DefaultSessionTTL,
|
||||||
|
MaxSession: DefaultMaxSession,
|
||||||
|
KickStrategy: models.KickStrategyKickOld,
|
||||||
|
}
|
||||||
|
var cfg models.AuthTenantAuthConfig
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
|
||||||
|
Filter("tid", tid).One(&cfg); err != nil {
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
if cfg.SessionTTL > 0 {
|
||||||
|
policy.SessionTTL = cfg.SessionTTL
|
||||||
|
}
|
||||||
|
if cfg.MaxSession > 0 {
|
||||||
|
policy.MaxSession = cfg.MaxSession
|
||||||
|
}
|
||||||
|
if cfg.KickStrategy == models.KickStrategyReject {
|
||||||
|
policy.KickStrategy = models.KickStrategyReject
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionInfo 创建会话的入参
|
||||||
|
type SessionInfo struct {
|
||||||
|
IdentityID uint64
|
||||||
|
Tid uint64
|
||||||
|
ClientID string
|
||||||
|
DeviceID string
|
||||||
|
DeviceName string
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
LoginType string
|
||||||
|
Amr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession 创建会话并执行并发控制。
|
||||||
|
//
|
||||||
|
// 并发策略(租户可配):
|
||||||
|
// - KickStrategyKickOld(默认):超出上限时踢掉最旧的会话(1号1机)
|
||||||
|
// - KickStrategyReject:超出上限时拒绝新登录
|
||||||
|
func CreateSession(info SessionInfo) (*models.AuthSession, error) {
|
||||||
|
policy := GetTenantSessionPolicy(info.Tid)
|
||||||
|
|
||||||
|
// 已占用的活跃会话
|
||||||
|
var actives []models.AuthSession
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||||
|
Filter("identity_id", info.IdentityID).
|
||||||
|
Filter("revoked", 0).
|
||||||
|
OrderBy("login_at").
|
||||||
|
All(&actives); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 过期会话先作废,不计入占用
|
||||||
|
now := time.Now()
|
||||||
|
valid := make([]models.AuthSession, 0, len(actives))
|
||||||
|
for _, s := range actives {
|
||||||
|
if s.ExpiresAt.Before(now) {
|
||||||
|
_ = RevokeSession(s.Sid, models.RevokeReasonExpired)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valid = append(valid, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valid) >= policy.MaxSession {
|
||||||
|
if policy.KickStrategy == models.KickStrategyReject {
|
||||||
|
return nil, ErrSessionLimitExceeded
|
||||||
|
}
|
||||||
|
// 踢掉最旧的,直到腾出名额
|
||||||
|
kick := len(valid) - policy.MaxSession + 1
|
||||||
|
for i := 0; i < kick && i < len(valid); i++ {
|
||||||
|
_ = RevokeSession(valid[i].Sid, models.RevokeReasonKicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sid, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ttl := time.Duration(policy.SessionTTL) * time.Second
|
||||||
|
s := &models.AuthSession{
|
||||||
|
Sid: sid,
|
||||||
|
IdentityID: info.IdentityID,
|
||||||
|
Tid: info.Tid,
|
||||||
|
ClientID: info.ClientID,
|
||||||
|
LoginType: orDefault(info.LoginType, "password"),
|
||||||
|
LoginAt: now,
|
||||||
|
LastAccessAt: now,
|
||||||
|
ExpiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
if info.DeviceID != "" {
|
||||||
|
s.DeviceID = &info.DeviceID
|
||||||
|
}
|
||||||
|
if info.DeviceName != "" {
|
||||||
|
s.DeviceName = &info.DeviceName
|
||||||
|
}
|
||||||
|
if info.IP != "" {
|
||||||
|
s.IP = &info.IP
|
||||||
|
}
|
||||||
|
if info.UserAgent != "" {
|
||||||
|
s.UserAgent = &info.UserAgent
|
||||||
|
}
|
||||||
|
if info.Amr != "" {
|
||||||
|
s.Amr = &info.Amr
|
||||||
|
}
|
||||||
|
if _, err := models.Orm.Insert(s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession 查询有效会话(已吊销或已过期返回错误)
|
||||||
|
func GetSession(sid string) (*models.AuthSession, error) {
|
||||||
|
var s models.AuthSession
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthSession)).Filter("sid", sid).One(&s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.Revoked != 0 {
|
||||||
|
return nil, ErrSessionRevoked
|
||||||
|
}
|
||||||
|
if s.ExpiresAt.Before(time.Now()) {
|
||||||
|
return nil, ErrSessionExpired
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TouchSession 更新会话最近访问时间(建议每 5~10 分钟一次,避免高频写库)
|
||||||
|
func TouchSession(sid string) error {
|
||||||
|
last := time.Now()
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||||
|
Filter("sid", sid).
|
||||||
|
Update(map[string]interface{}{"last_access_at": last})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchSessionTenant 切换当前会话所属企业(免密切换)
|
||||||
|
func SwitchSessionTenant(sid string, tid uint64) error {
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||||
|
Filter("sid", sid).
|
||||||
|
Update(map[string]interface{}{"tid": tid})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeSession 吊销单个会话
|
||||||
|
func RevokeSession(sid, reason string) error {
|
||||||
|
now := time.Now()
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||||
|
Filter("sid", sid).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"revoked": 1,
|
||||||
|
"revoke_reason": reason,
|
||||||
|
"revoke_at": now,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAllSessions 吊销该身份的全部会话(改密、管理员下线等场景)
|
||||||
|
func RevokeAllSessions(identityID uint64, reason string) error {
|
||||||
|
now := time.Now()
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||||
|
Filter("identity_id", identityID).
|
||||||
|
Filter("revoked", 0).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"revoked": 1,
|
||||||
|
"revoke_reason": reason,
|
||||||
|
"revoke_at": now,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveSessions 在线设备列表
|
||||||
|
func ListActiveSessions(identityID uint64) ([]models.AuthSession, error) {
|
||||||
|
var list []models.AuthSession
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||||
|
Filter("identity_id", identityID).
|
||||||
|
Filter("revoked", 0).
|
||||||
|
OrderBy("-login_at").
|
||||||
|
All(&list)
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomToken 生成 URL 安全的随机串(用于 sid / refresh token 明文)
|
||||||
|
func randomToken(n int) (string, error) {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(v, def string) string {
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/passwordutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncTenantUserInput 老表 → 认证中心同步入参
|
||||||
|
type SyncTenantUserInput struct {
|
||||||
|
Tid uint64
|
||||||
|
Account string // 企业内账号
|
||||||
|
Name string
|
||||||
|
Phone string
|
||||||
|
Email string
|
||||||
|
PasswordHash string // 已哈希的密码;为空表示不修改密码
|
||||||
|
GroupID uint64
|
||||||
|
OrgID uint64
|
||||||
|
Status int8
|
||||||
|
IsDefault int8
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTenantUser 把租户用户同步到统一认证中心(幂等,可重复调用)。
|
||||||
|
//
|
||||||
|
// 归并规则与迁移脚本一致:手机号 > 邮箱 > 企业内账号。
|
||||||
|
// - 身份不存在 → 创建;已存在 → 更新手机/邮箱/昵称/状态
|
||||||
|
// - 绑定不存在 → 创建;已存在 → 更新账号/姓名/部门/角色/状态
|
||||||
|
//
|
||||||
|
// 注意:密码属于身份层(一人一份),因此在任一企业修改密码,
|
||||||
|
// 该用户在其他企业的登录密码会同步变化——这是统一认证的预期行为。
|
||||||
|
func SyncTenantUser(in SyncTenantUserInput) error {
|
||||||
|
identity, err := findOrCreateIdentity(in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return upsertTenantBinding(identity, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveTenantUser 删除该用户在指定企业的绑定(保留身份本身,
|
||||||
|
// 因为该身份可能还绑定着其他企业)。
|
||||||
|
func RemoveTenantUser(tid uint64, account, phone, email string) error {
|
||||||
|
identity := findIdentityByKey(account, phone, email)
|
||||||
|
if identity == nil {
|
||||||
|
return nil // 认证中心无此身份,无需处理
|
||||||
|
}
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("identity_id", identity.ID).
|
||||||
|
Delete()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// findOrCreateIdentity 按归并键查找身份,不存在则创建
|
||||||
|
func findOrCreateIdentity(in SyncTenantUserInput) (*models.AuthIdentity, error) {
|
||||||
|
if m := findIdentityByKey(in.Account, in.Phone, in.Email); m != nil {
|
||||||
|
// 补全手机/邮箱/昵称;密码仅在显式传入时更新
|
||||||
|
update := map[string]interface{}{}
|
||||||
|
if in.Phone != "" {
|
||||||
|
update["mobile"] = in.Phone
|
||||||
|
}
|
||||||
|
if in.Email != "" {
|
||||||
|
update["email"] = in.Email
|
||||||
|
}
|
||||||
|
if in.Name != "" {
|
||||||
|
update["nickname"] = in.Name
|
||||||
|
}
|
||||||
|
if in.Status > 0 {
|
||||||
|
update["status"] = in.Status
|
||||||
|
}
|
||||||
|
if in.PasswordHash != "" {
|
||||||
|
update["password_hash"] = in.PasswordHash
|
||||||
|
update["password_algo"] = passwordutil.AlgoOf(in.PasswordHash)
|
||||||
|
}
|
||||||
|
if len(update) > 0 {
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", m.ID).
|
||||||
|
Update(update)
|
||||||
|
}
|
||||||
|
// 重新读取,返回最新值
|
||||||
|
_ = models.Orm.QueryTable(new(models.AuthIdentity)).Filter("id", m.ID).One(m)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &models.AuthIdentity{
|
||||||
|
UnionID: strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||||
|
PasswordAlgo: passwordutil.AlgoLegacy,
|
||||||
|
Status: in.Status,
|
||||||
|
}
|
||||||
|
if in.Status == 0 {
|
||||||
|
item.Status = models.AuthIdentityStatusEnabled
|
||||||
|
}
|
||||||
|
if in.Phone != "" {
|
||||||
|
item.Mobile = &in.Phone
|
||||||
|
}
|
||||||
|
if in.Email != "" {
|
||||||
|
item.Email = &in.Email
|
||||||
|
}
|
||||||
|
if in.Name != "" {
|
||||||
|
item.Nickname = &in.Name
|
||||||
|
}
|
||||||
|
if in.PasswordHash != "" {
|
||||||
|
item.PasswordHash = &in.PasswordHash
|
||||||
|
item.PasswordAlgo = passwordutil.AlgoOf(in.PasswordHash)
|
||||||
|
}
|
||||||
|
id, err := models.Orm.Insert(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.ID = uint64(id)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findIdentityByKey 按 手机 > 邮箱 > 账号 查找身份
|
||||||
|
func findIdentityByKey(account, phone, email string) *models.AuthIdentity {
|
||||||
|
base := models.Orm.QueryTable(new(models.AuthIdentity)).Filter("delete_time__isnull", true)
|
||||||
|
|
||||||
|
if v := strings.TrimSpace(phone); v != "" {
|
||||||
|
m := &models.AuthIdentity{}
|
||||||
|
if err := base.Filter("mobile", v).One(m); err == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(email); v != "" {
|
||||||
|
m := &models.AuthIdentity{}
|
||||||
|
if err := base.Filter("email", v).One(m); err == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 企业内账号:先找绑定关系再回查身份
|
||||||
|
if v := strings.TrimSpace(account); v != "" {
|
||||||
|
var bind models.AuthTenantUser
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||||
|
Filter("account", v).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
OrderBy("-is_default", "id").
|
||||||
|
One(&bind); err == nil {
|
||||||
|
m := &models.AuthIdentity{}
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", bind.IdentityID).One(m); err == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertTenantBinding 创建或更新身份-企业绑定
|
||||||
|
func upsertTenantBinding(identity *models.AuthIdentity, in SyncTenantUserInput) error {
|
||||||
|
var existed models.AuthTenantUser
|
||||||
|
err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||||
|
Filter("tid", in.Tid).
|
||||||
|
Filter("identity_id", identity.ID).
|
||||||
|
One(&existed)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// 新建绑定
|
||||||
|
item := &models.AuthTenantUser{
|
||||||
|
Tid: in.Tid,
|
||||||
|
IdentityID: identity.ID,
|
||||||
|
GroupID: in.GroupID,
|
||||||
|
OrgID: in.OrgID,
|
||||||
|
Status: in.Status,
|
||||||
|
IsDefault: in.IsDefault,
|
||||||
|
}
|
||||||
|
if in.Status == 0 {
|
||||||
|
item.Status = 1
|
||||||
|
}
|
||||||
|
if in.Account != "" {
|
||||||
|
item.Account = &in.Account
|
||||||
|
}
|
||||||
|
if in.Name != "" {
|
||||||
|
item.Name = &in.Name
|
||||||
|
}
|
||||||
|
if in.Phone != "" {
|
||||||
|
item.Phone = &in.Phone
|
||||||
|
}
|
||||||
|
if in.Email != "" {
|
||||||
|
item.Email = &in.Email
|
||||||
|
}
|
||||||
|
_, err := models.Orm.Insert(item)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
update := map[string]interface{}{}
|
||||||
|
if in.Account != "" {
|
||||||
|
update["account"] = in.Account
|
||||||
|
}
|
||||||
|
if in.Name != "" {
|
||||||
|
update["name"] = in.Name
|
||||||
|
}
|
||||||
|
if in.Phone != "" {
|
||||||
|
update["phone"] = in.Phone
|
||||||
|
}
|
||||||
|
if in.Email != "" {
|
||||||
|
update["email"] = in.Email
|
||||||
|
}
|
||||||
|
if in.GroupID > 0 {
|
||||||
|
update["group_id"] = in.GroupID
|
||||||
|
}
|
||||||
|
if in.OrgID > 0 {
|
||||||
|
update["org_id"] = in.OrgID
|
||||||
|
}
|
||||||
|
if in.Status > 0 {
|
||||||
|
update["status"] = in.Status
|
||||||
|
}
|
||||||
|
if in.IsDefault >= 0 {
|
||||||
|
update["is_default"] = in.IsDefault
|
||||||
|
}
|
||||||
|
if len(update) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||||
|
Filter("id", existed.ID).
|
||||||
|
Update(update)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 认证中心错误定义
|
||||||
|
var (
|
||||||
|
ErrSessionLimitExceeded = errors.New("同时在线设备数已达上限")
|
||||||
|
ErrSessionRevoked = errors.New("会话已失效,请重新登录")
|
||||||
|
ErrSessionExpired = errors.New("会话已过期,请重新登录")
|
||||||
|
ErrRefreshTokenInvalid = errors.New("刷新令牌无效或已过期")
|
||||||
|
ErrRefreshTokenReused = errors.New("刷新令牌已被使用,疑似重放攻击,已吊销该登录")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenPair 签发的令牌对
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Sid string `json:"sid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenIssue 签发令牌的入参
|
||||||
|
type TokenIssue struct {
|
||||||
|
IdentityID uint64
|
||||||
|
Tid uint64
|
||||||
|
ClientID string
|
||||||
|
Sid string
|
||||||
|
Username string
|
||||||
|
UserType string
|
||||||
|
Amr string
|
||||||
|
AccessTTL int // 秒
|
||||||
|
RefreshTTL int // 秒
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueTokens 签发访问令牌与刷新令牌。
|
||||||
|
// 刷新令牌明文只在本次返回,库中仅存哈希。
|
||||||
|
func IssueTokens(opt TokenIssue) (*TokenPair, error) {
|
||||||
|
accessTTL := opt.AccessTTL
|
||||||
|
if accessTTL <= 0 {
|
||||||
|
accessTTL = 1800
|
||||||
|
}
|
||||||
|
refreshTTL := opt.RefreshTTL
|
||||||
|
if refreshTTL <= 0 {
|
||||||
|
refreshTTL = 2592000
|
||||||
|
}
|
||||||
|
|
||||||
|
jti := uuid.NewString()
|
||||||
|
access, err := jwtutil.SignToken(jwtutil.TokenOptions{
|
||||||
|
Alg: jwtutil.AlgRS256,
|
||||||
|
UserID: int(opt.IdentityID),
|
||||||
|
Username: opt.Username,
|
||||||
|
TenantID: int(opt.Tid),
|
||||||
|
UserType: opt.UserType,
|
||||||
|
ClientID: opt.ClientID,
|
||||||
|
Sid: opt.Sid,
|
||||||
|
Amr: opt.Amr,
|
||||||
|
Subject: strconv.FormatUint(opt.IdentityID, 10),
|
||||||
|
Audience: []string{opt.ClientID},
|
||||||
|
Jti: jti,
|
||||||
|
TTL: time.Duration(accessTTL) * time.Second,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
plain, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
family := uuid.NewString()
|
||||||
|
now := time.Now()
|
||||||
|
rt := &models.AuthRefreshToken{
|
||||||
|
TokenHash: hashToken(plain),
|
||||||
|
IdentityID: opt.IdentityID,
|
||||||
|
Tid: opt.Tid,
|
||||||
|
ClientID: opt.ClientID,
|
||||||
|
Sid: opt.Sid,
|
||||||
|
FamilyID: family,
|
||||||
|
ExpiresAt: now.Add(time.Duration(refreshTTL) * time.Second),
|
||||||
|
}
|
||||||
|
if _, err := models.Orm.Insert(rt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenPair{
|
||||||
|
AccessToken: access,
|
||||||
|
RefreshToken: plain,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
ExpiresIn: accessTTL,
|
||||||
|
Sid: opt.Sid,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshTokens 用刷新令牌换取新的令牌对(轮换 + 重放检测)。
|
||||||
|
//
|
||||||
|
// 安全要点:检测到已使用的刷新令牌再次出现时,判定为重放,
|
||||||
|
// 吊销整个 family(该次登录的全部令牌)。
|
||||||
|
func RefreshTokens(plain, clientID string) (*TokenPair, error) {
|
||||||
|
h := hashToken(plain)
|
||||||
|
var rt models.AuthRefreshToken
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthRefreshToken)).Filter("token_hash", h).One(&rt); err != nil {
|
||||||
|
return nil, ErrRefreshTokenInvalid
|
||||||
|
}
|
||||||
|
if rt.ClientID != "" && clientID != "" && rt.ClientID != clientID {
|
||||||
|
return nil, ErrRefreshTokenInvalid
|
||||||
|
}
|
||||||
|
if rt.Revoked != 0 || rt.ExpiresAt.Before(time.Now()) {
|
||||||
|
return nil, ErrRefreshTokenInvalid
|
||||||
|
}
|
||||||
|
if rt.Used != 0 {
|
||||||
|
// 重放:吊销同族全部令牌与该会话
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||||
|
Filter("family_id", rt.FamilyID).
|
||||||
|
Update(map[string]interface{}{"revoked": 1})
|
||||||
|
_ = RevokeSession(rt.Sid, models.RevokeReasonAdmin)
|
||||||
|
return nil, ErrRefreshTokenReused
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记已用(保留 Row 以便审计)
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||||
|
Filter("id", rt.ID).
|
||||||
|
Update(map[string]interface{}{"used": 1})
|
||||||
|
|
||||||
|
// 会话校验:已吊销/过期则拒绝续期
|
||||||
|
s, err := GetSession(rt.Sid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = TouchSession(rt.Sid)
|
||||||
|
|
||||||
|
pair, err := IssueTokens(TokenIssue{
|
||||||
|
IdentityID: rt.IdentityID,
|
||||||
|
Tid: s.Tid, // 以会话当前企业为准(支持切换企业后刷新)
|
||||||
|
ClientID: rt.ClientID,
|
||||||
|
Sid: rt.Sid,
|
||||||
|
Amr: derefStr(s.Amr),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 新令牌继承同一 family,便于后续溯源与整族吊销
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||||
|
Filter("token_hash", hashToken(pair.RefreshToken)).
|
||||||
|
Update(map[string]interface{}{"family_id": rt.FamilyID, "rotated_from": h}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return pair, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeTokenPair 登出:吊销刷新令牌、会话,并把 access token 的 jti 加入黑名单。
|
||||||
|
func RevokeTokenPair(plain, accessToken, reason string) error {
|
||||||
|
if plain != "" {
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||||
|
Filter("token_hash", hashToken(plain)).
|
||||||
|
Update(map[string]interface{}{"revoked": 1})
|
||||||
|
}
|
||||||
|
if accessToken != "" {
|
||||||
|
if claims, err := jwtutil.ParseTokenRaw(accessToken); err == nil {
|
||||||
|
_ = Blacklist(claims.ID, claims.Sid, reason, time.Unix(claims.ExpiresAt.Unix(), 0))
|
||||||
|
if claims.Sid != "" {
|
||||||
|
_ = RevokeSession(claims.Sid, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blacklist 把 jti 加入吊销表
|
||||||
|
func Blacklist(jti, sid, reason string, expiresAt time.Time) error {
|
||||||
|
if jti == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
item := &models.AuthTokenBlacklist{
|
||||||
|
Jti: jti,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
if sid != "" {
|
||||||
|
item.Sid = &sid
|
||||||
|
}
|
||||||
|
if reason != "" {
|
||||||
|
item.Reason = &reason
|
||||||
|
}
|
||||||
|
_, err := models.Orm.InsertOrUpdate(item, "jti")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlacklisted 判断 jti 是否已被吊销
|
||||||
|
func IsBlacklisted(jti string) bool {
|
||||||
|
if jti == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return models.Orm.QueryTable(new(models.AuthTokenBlacklist)).Filter("jti", jti).Exist()
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(plain string) string {
|
||||||
|
sum := sha256.Sum256([]byte(plain))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefStr(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/pkg/passwordutil"
|
"server/pkg/passwordutil"
|
||||||
|
authsvc "server/services/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resetCodeItem 存储找回密码的验证码
|
// resetCodeItem 存储找回密码的验证码
|
||||||
@@ -250,9 +251,31 @@ func ResetPassword(tenantName, account, phone, smsCode, newPassword, confirmPass
|
|||||||
return errors.New("密码更新失败")
|
return errors.New("密码更新失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 双写:同步新密码到统一认证中心(失败不影响找回密码主流程)
|
||||||
|
_ = authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{
|
||||||
|
Tid: tenantUser.Tid,
|
||||||
|
Account: trimStrPtr(tenantUser.Account),
|
||||||
|
Name: trimStrPtr(tenantUser.Name),
|
||||||
|
Phone: trimStrPtr(tenantUser.Phone),
|
||||||
|
Email: trimStrPtr(tenantUser.Email),
|
||||||
|
PasswordHash: hashedPassword,
|
||||||
|
GroupID: tenantUser.GroupID,
|
||||||
|
OrgID: tenantUser.OrgID,
|
||||||
|
Status: tenantUser.Status,
|
||||||
|
IsDefault: tenantUser.IsDefault,
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trimStrPtr 取指针字符串并去空格;nil 时返回空串
|
||||||
|
func trimStrPtr(p *string) string {
|
||||||
|
if p == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*p)
|
||||||
|
}
|
||||||
|
|
||||||
// enqueueSMSTaskForPasswordReset 发送密码重置短信任务
|
// enqueueSMSTaskForPasswordReset 发送密码重置短信任务
|
||||||
func enqueueSMSTaskForPasswordReset(tid uint64, phone, content, code string) error {
|
func enqueueSMSTaskForPasswordReset(tid uint64, phone, content, code string) error {
|
||||||
// 重用已有的短信发送逻辑
|
// 重用已有的短信发送逻辑
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ func PlatformAdminLogin(account, password string) (string, *PlatformLoginUser, e
|
|||||||
if !passwordutil.Verify(user.Password, password) {
|
if !passwordutil.Verify(user.Password, password) {
|
||||||
return "", nil, errors.New("用户名或密码错误")
|
return "", nil, errors.New("用户名或密码错误")
|
||||||
}
|
}
|
||||||
|
// 历史 sha256 密码:本次登录成功后自动升级为 argon2id(失败不影响登录)
|
||||||
|
upgradePasswordIfNeeded(user.Password, password, func(hashed string) error {
|
||||||
|
_, err := models.Orm.QueryTable(new(models.AdminUser)).
|
||||||
|
Filter("id", user.ID).
|
||||||
|
Update(map[string]interface{}{"password": hashed})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
const tenantID = 0
|
const tenantID = 0
|
||||||
const userType = "platform"
|
const userType = "platform"
|
||||||
@@ -116,6 +123,13 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
|
|||||||
if tenantUser.Password == nil || !passwordutil.Verify(*tenantUser.Password, password) {
|
if tenantUser.Password == nil || !passwordutil.Verify(*tenantUser.Password, password) {
|
||||||
return "", nil, errors.New("用户名或密码错误")
|
return "", nil, errors.New("用户名或密码错误")
|
||||||
}
|
}
|
||||||
|
// 历史 sha256 密码:本次登录成功后自动升级为 argon2id(失败不影响登录)
|
||||||
|
upgradePasswordIfNeeded(*tenantUser.Password, password, func(hashed string) error {
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||||
|
Filter("id", tenantUser.ID).
|
||||||
|
Update(map[string]interface{}{"password": hashed})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
tenantID := int(tenant.ID)
|
tenantID := int(tenant.ID)
|
||||||
const userType = "backend"
|
const userType = "backend"
|
||||||
@@ -144,6 +158,22 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
|
|||||||
return token, loginUser, nil
|
return token, loginUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upgradePasswordIfNeeded 旧算法(legacy sha256)密码在登录成功后自动重新哈希为 argon2id。
|
||||||
|
//
|
||||||
|
// 旧哈希无法离线转换成新算法(无法反推明文),只能借登录时拿到的明文重新哈希,
|
||||||
|
// 因此采用「首次登录自动升级」的渐进方式,无需强制全员重置密码。
|
||||||
|
// 重新哈希失败不影响本次登录,仅下次登录时重试。
|
||||||
|
func upgradePasswordIfNeeded(stored, plain string, update func(hashed string) error) {
|
||||||
|
if !passwordutil.NeedsRehash(stored) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hashed, err := passwordutil.Hash(plain)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = update(hashed)
|
||||||
|
}
|
||||||
|
|
||||||
// PlatformGetCurrentUser 根据平台管理员用户 ID 返回登录用户信息(含角色名称)。
|
// PlatformGetCurrentUser 根据平台管理员用户 ID 返回登录用户信息(含角色名称)。
|
||||||
func PlatformGetCurrentUser(uid uint64) (*PlatformLoginUser, error) {
|
func PlatformGetCurrentUser(uid uint64) (*PlatformLoginUser, error) {
|
||||||
u, err := GetAdminUserByID(uid)
|
u, err := GetAdminUserByID(uid)
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<!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: 420px; max-width: 92vw; padding: 40px 36px;
|
||||||
|
}
|
||||||
|
.logo { text-align: center; margin-bottom: 28px; }
|
||||||
|
.logo h1 { font-size: 22px; color: #1a1a2e; font-weight: 600; }
|
||||||
|
.logo p { font-size: 13px; color: #8a8fa3; margin-top: 6px; }
|
||||||
|
.field { margin-bottom: 18px; }
|
||||||
|
.field label { display: block; font-size: 13px; color: #5a6072; margin-bottom: 7px; }
|
||||||
|
.field input {
|
||||||
|
width: 100%; height: 44px; padding: 0 14px; font-size: 14px;
|
||||||
|
border: 1px solid #dcdfe8; border-radius: 8px; outline: none; transition: border-color .2s;
|
||||||
|
}
|
||||||
|
.field input:focus { border-color: #667eea; }
|
||||||
|
.btn {
|
||||||
|
width: 100%; height: 44px; border: none; border-radius: 8px; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff; font-size: 15px; font-weight: 500; transition: opacity .2s;
|
||||||
|
}
|
||||||
|
.btn:hover { opacity: .9; }
|
||||||
|
.btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
|
.error {
|
||||||
|
background: #fff2f0; border: 1px solid #ffccc7; color: #cf1322;
|
||||||
|
padding: 10px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 16px; display: none;
|
||||||
|
}
|
||||||
|
.tenant-list { list-style: none; }
|
||||||
|
.tenant-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 16px; border: 1px solid #e4e7ee; border-radius: 8px;
|
||||||
|
margin-bottom: 12px; cursor: pointer; transition: all .2s;
|
||||||
|
}
|
||||||
|
.tenant-item:hover { border-color: #667eea; background: #f7f8ff; }
|
||||||
|
.tenant-name { font-size: 15px; color: #1a1a2e; font-weight: 500; }
|
||||||
|
.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; }
|
||||||
|
.back { text-align: center; margin-top: 14px; }
|
||||||
|
.back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">
|
||||||
|
<h1>云泽统一认证中心</h1>
|
||||||
|
<p>{{if eq .Step "tenant"}}请选择要进入的企业{{else}}登录后即可使用全部应用{{end}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error" id="error"></div>
|
||||||
|
|
||||||
|
{{if eq .Step "tenant"}}
|
||||||
|
<!-- 企业选择步骤 -->
|
||||||
|
<ul class="tenant-list">
|
||||||
|
{{range .Tenants}}
|
||||||
|
<li class="tenant-item" onclick="chooseTenant({{.Tid}})">
|
||||||
|
<div>
|
||||||
|
<div class="tenant-name">{{.TenantName}}</div>
|
||||||
|
<div class="tenant-meta">{{if .Name}}{{.Name}} · {{end}}企业ID {{.Tid}}</div>
|
||||||
|
</div>
|
||||||
|
<span class="tenant-arrow">›</span>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<div class="back"><a onclick="logout()">切换账号</a></div>
|
||||||
|
{{else}}
|
||||||
|
<!-- 账号密码登录 -->
|
||||||
|
<form id="loginForm" onsubmit="return submitLogin(event)">
|
||||||
|
<div class="field">
|
||||||
|
<label>账号</label>
|
||||||
|
<input id="account" type="text" placeholder="手机号 / 邮箱 / 企业账号" autocomplete="username" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>密码</label>
|
||||||
|
<input id="password" type="password" placeholder="请输入密码" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="submitBtn" type="submit">登 录</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="tip">统一身份认证 · 一次登录,全平台通行</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var CLIENT_ID = "{{.ClientID}}";
|
||||||
|
var REDIRECT = "{{.Redirect}}";
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
var el = document.getElementById('error');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitLogin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var account = document.getElementById('account').value.trim();
|
||||||
|
var password = document.getElementById('password').value;
|
||||||
|
if (!account) { showError('请输入账号'); return false; }
|
||||||
|
if (!password) { showError('请输入密码'); return false; }
|
||||||
|
|
||||||
|
var btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '登录中...';
|
||||||
|
|
||||||
|
fetch('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ account: account, password: password, client_id: CLIENT_ID, redirect: REDIRECT })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.code !== 200) {
|
||||||
|
showError(res.msg || '登录失败');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '登 录';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.need_choose_tenant) {
|
||||||
|
window.location.href = '/auth/login?step=tenant&client_id=' + encodeURIComponent(CLIENT_ID) + '&redirect=' + encodeURIComponent(REDIRECT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.redirect) { window.location.href = res.redirect; return; }
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('网络异常,请稍后重试');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '登 录';
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseTenant(tid) {
|
||||||
|
fetch('/auth/switch-tenant', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tid: tid, client_id: CLIENT_ID, redirect: REDIRECT })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.code !== 200) { showError(res.msg || '切换失败'); return; }
|
||||||
|
if (res.redirect) { window.location.href = res.redirect; return; }
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
fetch('/auth/logout', { method: 'POST' }).then(function () {
|
||||||
|
window.location.href = '/auth/login?client_id=' + encodeURIComponent(CLIENT_ID) + '&redirect=' + encodeURIComponent(REDIRECT);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -229,16 +229,16 @@ const performLoginRequest = async () => {
|
|||||||
|
|
||||||
if (res && res.code === 200) {
|
if (res && res.code === 200) {
|
||||||
// 登录成功:处理"记住我"
|
// 登录成功:处理"记住我"
|
||||||
|
// 安全:只记住账号,绝不明文存储密码
|
||||||
if (rememberMe.value) {
|
if (rememberMe.value) {
|
||||||
localStorage.setItem("loginAccount", account.value);
|
localStorage.setItem("loginAccount", account.value);
|
||||||
localStorage.setItem("loginPassword", password.value);
|
|
||||||
localStorage.setItem("loginRememberMe", "true");
|
localStorage.setItem("loginRememberMe", "true");
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("loginAccount");
|
localStorage.removeItem("loginAccount");
|
||||||
localStorage.removeItem("loginTenantName");
|
localStorage.removeItem("loginTenantName");
|
||||||
localStorage.removeItem("loginPassword");
|
|
||||||
localStorage.setItem("loginRememberMe", "false");
|
localStorage.setItem("loginRememberMe", "false");
|
||||||
}
|
}
|
||||||
|
localStorage.removeItem("loginPassword");
|
||||||
|
|
||||||
authStore.setLoginInfo(res.data);
|
authStore.setLoginInfo(res.data);
|
||||||
|
|
||||||
@@ -353,16 +353,16 @@ const startGeetest4 = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (loginRes && loginRes.code === 200) {
|
if (loginRes && loginRes.code === 200) {
|
||||||
|
// 安全:只记住账号,绝不明文存储密码
|
||||||
if (rememberMe.value) {
|
if (rememberMe.value) {
|
||||||
localStorage.setItem("loginAccount", account.value);
|
localStorage.setItem("loginAccount", account.value);
|
||||||
localStorage.setItem("loginPassword", password.value);
|
|
||||||
localStorage.setItem("loginRememberMe", "true");
|
localStorage.setItem("loginRememberMe", "true");
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("loginAccount");
|
localStorage.removeItem("loginAccount");
|
||||||
localStorage.removeItem("loginTenantName");
|
localStorage.removeItem("loginTenantName");
|
||||||
localStorage.removeItem("loginPassword");
|
|
||||||
localStorage.setItem("loginRememberMe", "false");
|
localStorage.setItem("loginRememberMe", "false");
|
||||||
}
|
}
|
||||||
|
localStorage.removeItem("loginPassword");
|
||||||
|
|
||||||
authStore.setLoginInfo(loginRes.data);
|
authStore.setLoginInfo(loginRes.data);
|
||||||
const { useTabsStore } = await import("@/stores");
|
const { useTabsStore } = await import("@/stores");
|
||||||
@@ -468,7 +468,7 @@ const handleRememberMeChange = async () => {
|
|||||||
if (rememberMe.value) {
|
if (rememberMe.value) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
"请确认电脑环境是可信的,登录成功后会自动记住密码。",
|
"请确认电脑环境是可信的,登录成功后会自动记住账号(不会保存密码)。",
|
||||||
"安全提示",
|
"安全提示",
|
||||||
{
|
{
|
||||||
confirmButtonText: "确定",
|
confirmButtonText: "确定",
|
||||||
@@ -489,7 +489,6 @@ onMounted(() => {
|
|||||||
const savedRemember = localStorage.getItem("loginRememberMe");
|
const savedRemember = localStorage.getItem("loginRememberMe");
|
||||||
if (savedRemember === "true") {
|
if (savedRemember === "true") {
|
||||||
account.value = localStorage.getItem("loginAccount") || "";
|
account.value = localStorage.getItem("loginAccount") || "";
|
||||||
password.value = localStorage.getItem("loginPassword") || "";
|
|
||||||
rememberMe.value = true;
|
rememberMe.value = true;
|
||||||
}
|
}
|
||||||
generateCaptcha();
|
generateCaptcha();
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import type { FormInstance, FormRules } from 'element-plus';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
|
||||||
|
interface UpgradeItem {
|
||||||
|
id?: number;
|
||||||
|
tenant_id: string;
|
||||||
|
update_date: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
sort: number;
|
||||||
|
status: number;
|
||||||
|
create_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData = ref<UpgradeItem[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
|
||||||
|
// 表单对话框
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const dialogTitle = ref('');
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const formData = ref<Partial<UpgradeItem>>({
|
||||||
|
tenant_id: '',
|
||||||
|
update_date: new Date().toISOString().split('T')[0],
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
sort: 0,
|
||||||
|
status: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单验证
|
||||||
|
const validateForm = ref<FormRules>({});
|
||||||
|
|
||||||
|
// 获取列表
|
||||||
|
const fetchList = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/platform/api/upgrade/list?limit=${pageSize.value * 2}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.code === 200) {
|
||||||
|
tableData.value = result.data;
|
||||||
|
total.value = result.data.length;
|
||||||
|
} else {
|
||||||
|
ElMessage.error(result.msg || '获取数据失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取数据失败:', error);
|
||||||
|
ElMessage.error('获取数据失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开新增对话框
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
dialogTitle.value = '新增更新内容';
|
||||||
|
formData.value = {
|
||||||
|
tenant_id: '',
|
||||||
|
update_date: new Date().toISOString().split('T')[0],
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
sort: 0,
|
||||||
|
status: 1,
|
||||||
|
};
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开编辑对话框
|
||||||
|
const openEditDialog = (row: UpgradeItem) => {
|
||||||
|
dialogTitle.value = '编辑更新内容';
|
||||||
|
formData.value = { ...row };
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
|
||||||
|
await formRef.value.validate((valid) => {
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
// TODO: 调用后端保存接口
|
||||||
|
ElMessage.success('保存成功');
|
||||||
|
dialogVisible.value = false;
|
||||||
|
fetchList();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = async (row: UpgradeItem) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除"${row.title}"吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: 调用后端删除接口
|
||||||
|
ElMessage.success('删除成功');
|
||||||
|
fetchList();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchList();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="platform-upgrade">
|
||||||
|
<!-- 操作栏 -->
|
||||||
|
<el-card shadow="never" class="toolbar-card">
|
||||||
|
<el-button type="primary" @click="openCreateDialog">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新增更新内容
|
||||||
|
</el-button>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<el-card shadow="never" class="table-card">
|
||||||
|
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||||
|
<el-table-column prop="update_date" label="更新日期" width="120" />
|
||||||
|
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="content" label="更新内容" min-width="300" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
||||||
|
{{ row.status === 1 ? '显示' : '隐藏' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="创建时间" width="180" />
|
||||||
|
<el-table-column label="操作" width="180" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新增/编辑对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="600px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formData"
|
||||||
|
:rules="validateForm"
|
||||||
|
label-width="100px"
|
||||||
|
label-position="top"
|
||||||
|
>
|
||||||
|
<el-form-item label="更新日期" prop="update_date">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="formData.update_date"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="标题" prop="title">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.title"
|
||||||
|
placeholder="请输入标题"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="更新内容" prop="content">
|
||||||
|
<el-input
|
||||||
|
v-model="formData.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="8"
|
||||||
|
placeholder="请输入更新内容(支持 HTML)"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="排序" prop="sort">
|
||||||
|
<el-input-number
|
||||||
|
v-model="formData.sort"
|
||||||
|
:min="0"
|
||||||
|
:max="9999"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
<span class="tip">数值越大越靠前</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="formData.status">
|
||||||
|
<el-radio :label="1">显示</el-radio>
|
||||||
|
<el-radio :label="0">隐藏</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.platform-upgrade {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-card,
|
||||||
|
.table-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+47
-1
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { request, requestRaw } from '@/api/request.js'
|
import { request, requestRaw } from '@/api/request.js'
|
||||||
|
|
||||||
/** 账号密码登录 */
|
/** 账号密码登录(旧:租户端直登) */
|
||||||
export function login(data) {
|
export function login(data) {
|
||||||
return request({
|
return request({
|
||||||
url: '/app/login',
|
url: '/app/login',
|
||||||
@@ -12,6 +12,52 @@ export function login(data) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 统一认证中心(UAC) ====================
|
||||||
|
* APP 不走浏览器跳转,直接用密码直连模式:
|
||||||
|
* POST /auth/login 拿 access + refresh,多企业时再调 switch-tenant。
|
||||||
|
* 开关:VITE_AUTH_MODE=sso 时启用(见 utils/auth.js 的 isSSOEnabled)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 统一认证:账号密码登录 */
|
||||||
|
export function loginUAC(data) {
|
||||||
|
return request({
|
||||||
|
url: '/auth/login',
|
||||||
|
method: 'POST',
|
||||||
|
data: Object.assign({ client_id: 'yz-uniapp' }, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一认证:选择/切换企业(一人多企业) */
|
||||||
|
export function switchTenant(data) {
|
||||||
|
return request({
|
||||||
|
url: '/auth/switch-tenant',
|
||||||
|
method: 'POST',
|
||||||
|
data: Object.assign({ client_id: 'yz-uniapp' }, data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一认证:刷新访问令牌 */
|
||||||
|
export function refreshTokenUAC(data) {
|
||||||
|
return request({
|
||||||
|
url: '/auth/token',
|
||||||
|
method: 'POST',
|
||||||
|
header: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
data: Object.assign({ grant_type: 'refresh_token', client_id: 'yz-uniapp' }, data),
|
||||||
|
silent: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一认证:吊销令牌(登出) */
|
||||||
|
export function revokeUAC(data = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/auth/revoke',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
silent: true,
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 发送登录验证码(账号二次校验用,需后台开启验证) */
|
/** 发送登录验证码(账号二次校验用,需后台开启验证) */
|
||||||
export function sendLoginCode(data) {
|
export function sendLoginCode(data) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
+69
-2
@@ -2,7 +2,7 @@
|
|||||||
* 统一请求封装:baseURL、Bearer Token、业务 code 处理。
|
* 统一请求封装:baseURL、Bearer Token、业务 code 处理。
|
||||||
* 接口地址统一从 .env 的 VITE_API_BASE_URL 读取(见 api/config.js)。
|
* 接口地址统一从 .env 的 VITE_API_BASE_URL 读取(见 api/config.js)。
|
||||||
*/
|
*/
|
||||||
import { getToken, logout } from '@/utils/auth.js'
|
import { getToken, logout, isSSOEnabled, getRefreshToken, setAuthTokens } from '@/utils/auth.js'
|
||||||
import { resolveBaseURL, getBaseURL } from '@/api/config.js'
|
import { resolveBaseURL, getBaseURL } from '@/api/config.js'
|
||||||
|
|
||||||
export { getBaseURL }
|
export { getBaseURL }
|
||||||
@@ -11,6 +11,63 @@ function buildFullUrl(url) {
|
|||||||
return resolveBaseURL().replace(/\/$/, '') + url
|
return resolveBaseURL().replace(/\/$/, '') + url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用 refresh_token 换取新的访问令牌(独立封装,避免与 request 递归调用)。
|
||||||
|
* 仅在统一认证模式(VITE_AUTH_MODE=sso)下使用。
|
||||||
|
*/
|
||||||
|
function refreshAccessToken() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const refresh = getRefreshToken()
|
||||||
|
if (!refresh) {
|
||||||
|
reject(new Error('无刷新令牌'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.request({
|
||||||
|
url: buildFullUrl('/auth/token'),
|
||||||
|
method: 'POST',
|
||||||
|
header: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
data: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refresh)}&client_id=yz-uniapp`,
|
||||||
|
timeout: 10000,
|
||||||
|
success(res) {
|
||||||
|
const body = res.data || {}
|
||||||
|
if (res.statusCode === 200 && body.access_token) {
|
||||||
|
setAuthTokens(body)
|
||||||
|
resolve(body.access_token)
|
||||||
|
} else {
|
||||||
|
reject(new Error((body && (body.error || body.msg)) || '刷新失败'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 续期后重放原请求,返回与 request 一致的 data 解包结果 */
|
||||||
|
async function replayRequest(options) {
|
||||||
|
const token = await refreshAccessToken()
|
||||||
|
const res = await new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
url: buildFullUrl(options.url),
|
||||||
|
method: (options.method || 'GET').toUpperCase(),
|
||||||
|
data: options.data || {},
|
||||||
|
header: Object.assign(
|
||||||
|
{ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
options.header || {}
|
||||||
|
),
|
||||||
|
timeout: options.timeout || 30000,
|
||||||
|
success: (r) => resolve(r.data || {}),
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (typeof res.code !== 'undefined' && res.code !== 200) {
|
||||||
|
const err = new Error(res.msg || '操作失败')
|
||||||
|
err.code = res.code
|
||||||
|
err.response = res
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return typeof res.data !== 'undefined' ? res.data : res
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ url: string, method?: string, data?: object, header?: object, silent?: boolean }} options
|
* @param {{ url: string, method?: string, data?: object, header?: object, silent?: boolean }} options
|
||||||
* @returns {Promise<any>} 业务 data 字段(若无 data 则返回整包)
|
* @returns {Promise<any>} 业务 data 字段(若无 data 则返回整包)
|
||||||
@@ -35,11 +92,21 @@ export function request(options = {}) {
|
|||||||
data: data || {},
|
data: data || {},
|
||||||
header: headers,
|
header: headers,
|
||||||
timeout,
|
timeout,
|
||||||
success(res) {
|
async success(res) {
|
||||||
const status = res.statusCode
|
const status = res.statusCode
|
||||||
const body = res.data || {}
|
const body = res.data || {}
|
||||||
|
|
||||||
if (status === 401 || body.code === 401) {
|
if (status === 401 || body.code === 401) {
|
||||||
|
// 统一认证模式:先用 refresh_token 静默续期并重放请求,
|
||||||
|
// 续期失败(令牌过期/被吊销/被踢下线)才真正登出。
|
||||||
|
if (isSSOEnabled()) {
|
||||||
|
try {
|
||||||
|
resolve(await replayRequest(options))
|
||||||
|
return
|
||||||
|
} catch (e) {
|
||||||
|
// 续期失败,继续走下面的登出流程
|
||||||
|
}
|
||||||
|
}
|
||||||
logout()
|
logout()
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
uni.showToast({ title: body.msg || '请重新登录', icon: 'none' })
|
uni.showToast({ title: body.msg || '请重新登录', icon: 'none' })
|
||||||
|
|||||||
@@ -137,7 +137,9 @@ import {
|
|||||||
login,
|
login,
|
||||||
loginBySms,
|
loginBySms,
|
||||||
sendLoginCode,
|
sendLoginCode,
|
||||||
fetchVerifyConfig
|
fetchVerifyConfig,
|
||||||
|
loginUAC,
|
||||||
|
switchTenant
|
||||||
} from '@/api/auth.js'
|
} from '@/api/auth.js'
|
||||||
import {
|
import {
|
||||||
loginSuccess,
|
loginSuccess,
|
||||||
@@ -146,7 +148,11 @@ import {
|
|||||||
getTenantName,
|
getTenantName,
|
||||||
getRememberLogin,
|
getRememberLogin,
|
||||||
saveRememberLogin,
|
saveRememberLogin,
|
||||||
clearRememberLogin
|
clearRememberLogin,
|
||||||
|
isSSOEnabled,
|
||||||
|
setAuthTokens,
|
||||||
|
setTid,
|
||||||
|
getToken
|
||||||
} from '@/utils/auth.js'
|
} from '@/utils/auth.js'
|
||||||
import { showGeetest4 } from '@/utils/geetest.js'
|
import { showGeetest4 } from '@/utils/geetest.js'
|
||||||
|
|
||||||
@@ -191,7 +197,6 @@ onMounted(async () => {
|
|||||||
rememberMe.value = true
|
rememberMe.value = true
|
||||||
tenantName.value = remembered.tenantName
|
tenantName.value = remembered.tenantName
|
||||||
username.value = remembered.account
|
username.value = remembered.account
|
||||||
password.value = remembered.password
|
|
||||||
} else {
|
} else {
|
||||||
tenantName.value = getTenantName()
|
tenantName.value = getTenantName()
|
||||||
}
|
}
|
||||||
@@ -276,6 +281,84 @@ function navigateAfterLogin() {
|
|||||||
}, 400)
|
}, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 统一认证(UAC)登录 ====================
|
||||||
|
* APP 无浏览器回跳,走密码直连:
|
||||||
|
* 1) POST /auth/login 拿 access + refresh
|
||||||
|
* 2) 一人多企业时弹出企业列表让用户选,选完调 /auth/switch-tenant
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 选择要进入的企业(一人多企业场景) */
|
||||||
|
function chooseTenant(tenants) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const names = (tenants || []).map(t => t.tenant_name || `企业${t.tid}`)
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: names,
|
||||||
|
title: '选择要进入的企业',
|
||||||
|
success: (res) => resolve(tenants[res.tapIndex]),
|
||||||
|
fail: () => reject(new Error('已取消选择'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一认证:账号密码登录 */
|
||||||
|
async function submitSSOLogin() {
|
||||||
|
const account = username.value.trim()
|
||||||
|
const pwd = password.value
|
||||||
|
if (!account) {
|
||||||
|
uni.showToast({ title: '请输入账号', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!pwd) {
|
||||||
|
uni.showToast({ title: '请输入密码', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await loginUAC({ account, password: pwd })
|
||||||
|
if (!data || data.code !== 200) {
|
||||||
|
uni.showToast({ title: (data && data.msg) || '登录失败', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记住账号(不含密码)
|
||||||
|
if (rememberMe.value) {
|
||||||
|
saveRememberLogin({ tenantName: tenantName.value.trim(), account })
|
||||||
|
} else {
|
||||||
|
clearRememberLogin()
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentTid = 0
|
||||||
|
if (data.need_choose_tenant) {
|
||||||
|
// 多企业:先记住令牌,再让用户选择
|
||||||
|
setAuthTokens(data.tokens || { access_token: data.access_token, refresh_token: data.refresh_token, sid: data.sid })
|
||||||
|
const picked = await chooseTenant(data.tenants)
|
||||||
|
const res = await switchTenant({ tid: picked.tid })
|
||||||
|
if (res && res.code === 200 && res.data && res.data.tokens) {
|
||||||
|
setAuthTokens(res.data.tokens)
|
||||||
|
currentTid = picked.tid
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: (res && res.msg) || '进入企业失败', icon: 'none' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAuthTokens(data.tokens || {})
|
||||||
|
if (data.tenants && data.tenants.length > 0) currentTid = data.tenants[0].tid
|
||||||
|
}
|
||||||
|
|
||||||
|
setTid(currentTid)
|
||||||
|
loginSuccess({
|
||||||
|
token: getToken(),
|
||||||
|
user: {
|
||||||
|
id: data.identity ? data.identity.id : 0,
|
||||||
|
account,
|
||||||
|
name: (data.identity && (data.identity.nickname || data.identity.mobile)) || account,
|
||||||
|
tid: currentTid,
|
||||||
|
tenant_name: (data.tenants || []).map(t => t.tenant_name).join('、')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
navigateAfterLogin()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function submitAccountLogin(geetestResult = null) {
|
async function submitAccountLogin(geetestResult = null) {
|
||||||
if (!username.value.trim()) {
|
if (!username.value.trim()) {
|
||||||
uni.showToast({ title: '请输入账号', icon: 'none' })
|
uni.showToast({ title: '请输入账号', icon: 'none' })
|
||||||
@@ -311,8 +394,7 @@ async function submitAccountLogin(geetestResult = null) {
|
|||||||
if (rememberMe.value) {
|
if (rememberMe.value) {
|
||||||
saveRememberLogin({
|
saveRememberLogin({
|
||||||
tenantName: tenantName.value.trim(),
|
tenantName: tenantName.value.trim(),
|
||||||
account: username.value.trim(),
|
account: username.value.trim()
|
||||||
password: password.value
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
clearRememberLogin()
|
clearRememberLogin()
|
||||||
@@ -326,6 +408,16 @@ async function handleLogin() {
|
|||||||
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
|
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 统一认证模式:账号即手机/邮箱,无需填写租户名
|
||||||
|
if (isSSOEnabled()) {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await submitSSOLogin()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!tenantName.value.trim()) {
|
if (!tenantName.value.trim()) {
|
||||||
uni.showToast({ title: '请输入租户名称', icon: 'none' })
|
uni.showToast({ title: '请输入租户名称', icon: 'none' })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -39,45 +39,6 @@
|
|||||||
<view v-else class="schedule-list">
|
<view v-else class="schedule-list">
|
||||||
<view v-for="group in groupedList" :key="group.key" class="group">
|
<view v-for="group in groupedList" :key="group.key" class="group">
|
||||||
<text class="group-title">{{ group.label }}</text>
|
<text class="group-title">{{ group.label }}</text>
|
||||||
<view
|
|
||||||
v-for="item in group.items"
|
|
||||||
:key="item.id"
|
|
||||||
class="schedule-card"
|
|
||||||
:class="{ done: item.completed }"
|
|
||||||
>
|
|
||||||
<view class="schedule-main" @tap="openDetail(item.id)">
|
|
||||||
<view class="schedule-head">
|
|
||||||
<view class="status-dot" :class="item.completed ? 'done' : 'pending'" />
|
|
||||||
<text class="schedule-title">{{ getScheduleSummary(item) }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="schedule-meta">
|
|
||||||
<FaIcon name="clock" color="#909399" :size="12" />
|
|
||||||
<text>{{ formatDate(item.datetime, true) }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="item.remindChannels?.length" class="channel-row">
|
|
||||||
<text
|
|
||||||
v-for="ch in item.remindChannels"
|
|
||||||
:key="ch"
|
|
||||||
class="channel-mini"
|
|
||||||
>{{ getRemindChannelLabel(ch) }}</text>
|
|
||||||
<text v-if="item.remindMinutes" class="remind-text">{{ getRemindMinutesLabel(item.remindMinutes) }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="action-row">
|
|
||||||
<view
|
|
||||||
class="action-btn"
|
|
||||||
:class="{ disabled: item.completed }"
|
|
||||||
@tap="!item.completed && onToggleComplete(item)"
|
|
||||||
>
|
|
||||||
<FaIcon name="check" :color="item.completed ? '#c0c4cc' : '#3c9cff'" :size="14" />
|
|
||||||
<text>{{ item.completed ? '已完成' : '标记完成' }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="!item.completed" class="action-btn" @tap="openEdit(item.id)">
|
|
||||||
<FaIcon name="pen-to-square" color="#3c9cff" :size="14" />
|
|
||||||
<text>编辑</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="bottom-space" />
|
<view class="bottom-space" />
|
||||||
|
|||||||
+47
-7
@@ -1,6 +1,41 @@
|
|||||||
const TOKEN_KEY = 'app_token'
|
const TOKEN_KEY = 'app_token'
|
||||||
const USER_KEY = 'app_user'
|
const USER_KEY = 'app_user'
|
||||||
const TENANT_KEY = 'app_tenant_name'
|
const TENANT_KEY = 'app_tenant_name'
|
||||||
|
const TID_KEY = 'app_tid'
|
||||||
|
const REFRESH_KEY = 'app_refresh_token'
|
||||||
|
const SID_KEY = 'app_sid'
|
||||||
|
|
||||||
|
/** 是否启用统一认证:.env 中配置 VITE_AUTH_MODE=sso */
|
||||||
|
export function isSSOEnabled() {
|
||||||
|
try {
|
||||||
|
return (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_AUTH_MODE) === 'sso'
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一认证:保存令牌与会话信息 */
|
||||||
|
export function setAuthTokens(data = {}) {
|
||||||
|
if (data.access_token) setToken(data.access_token)
|
||||||
|
if (data.refresh_token) uni.setStorageSync(REFRESH_KEY, data.refresh_token)
|
||||||
|
if (data.sid) uni.setStorageSync(SID_KEY, data.sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken() {
|
||||||
|
return uni.getStorageSync(REFRESH_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSid() {
|
||||||
|
return uni.getStorageSync(SID_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTid(tid) {
|
||||||
|
uni.setStorageSync(TID_KEY, tid || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTid() {
|
||||||
|
return Number(uni.getStorageSync(TID_KEY) || 0)
|
||||||
|
}
|
||||||
const REMEMBER_KEY = 'app_remember_me'
|
const REMEMBER_KEY = 'app_remember_me'
|
||||||
const REMEMBER_TENANT_KEY = 'app_remember_tenant'
|
const REMEMBER_TENANT_KEY = 'app_remember_tenant'
|
||||||
const REMEMBER_ACCOUNT_KEY = 'app_remember_account'
|
const REMEMBER_ACCOUNT_KEY = 'app_remember_account'
|
||||||
@@ -37,6 +72,8 @@ export function isLoggedIn() {
|
|||||||
export function logout() {
|
export function logout() {
|
||||||
uni.removeStorageSync(TOKEN_KEY)
|
uni.removeStorageSync(TOKEN_KEY)
|
||||||
uni.removeStorageSync(USER_KEY)
|
uni.removeStorageSync(USER_KEY)
|
||||||
|
uni.removeStorageSync(REFRESH_KEY)
|
||||||
|
uni.removeStorageSync(SID_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否开启记住账号登录信息 */
|
/** 是否开启记住账号登录信息 */
|
||||||
@@ -44,25 +81,28 @@ export function isRememberLogin() {
|
|||||||
return uni.getStorageSync(REMEMBER_KEY) === '1'
|
return uni.getStorageSync(REMEMBER_KEY) === '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取记住的账号登录信息 */
|
/**
|
||||||
|
* 读取记住的账号登录信息。
|
||||||
|
* 安全说明:不再保存/回填密码,仅记住租户名与账号。
|
||||||
|
*/
|
||||||
export function getRememberLogin() {
|
export function getRememberLogin() {
|
||||||
if (!isRememberLogin()) {
|
if (!isRememberLogin()) {
|
||||||
return { rememberMe: false, tenantName: '', account: '', password: '' }
|
return { rememberMe: false, tenantName: '', account: '' }
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
rememberMe: true,
|
rememberMe: true,
|
||||||
tenantName: uni.getStorageSync(REMEMBER_TENANT_KEY) || '',
|
tenantName: uni.getStorageSync(REMEMBER_TENANT_KEY) || '',
|
||||||
account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || '',
|
account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || ''
|
||||||
password: uni.getStorageSync(REMEMBER_PASSWORD_KEY) || ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存账号登录信息(租户、账号、密码) */
|
/** 保存账号登录信息(租户、账号;不含密码) */
|
||||||
export function saveRememberLogin({ tenantName = '', account = '', password = '' } = {}) {
|
export function saveRememberLogin({ tenantName = '', account = '' } = {}) {
|
||||||
uni.setStorageSync(REMEMBER_KEY, '1')
|
uni.setStorageSync(REMEMBER_KEY, '1')
|
||||||
uni.setStorageSync(REMEMBER_TENANT_KEY, tenantName)
|
uni.setStorageSync(REMEMBER_TENANT_KEY, tenantName)
|
||||||
uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account)
|
uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account)
|
||||||
uni.setStorageSync(REMEMBER_PASSWORD_KEY, password)
|
// 历史版本可能已落盘明文密码,这里一并清理
|
||||||
|
uni.removeStorageSync(REMEMBER_PASSWORD_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除记住的账号登录信息 */
|
/** 清除记住的账号登录信息 */
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { getJSON } from "@/api/request";
|
import { getJSON } from "@/api/request";
|
||||||
import { navItems } from "./site";
|
import { navItems } from "./site";
|
||||||
|
import { sections } from "./sections";
|
||||||
|
|
||||||
export interface NavMenuItem {
|
export interface NavMenuItem {
|
||||||
/** v-for key:后端菜单用 nav-{id},静态回退用 static-{path} */
|
/** v-for key:后端菜单用 nav-{id},静态回退用 static-{path} */
|
||||||
@@ -53,13 +54,28 @@ function toMenuItem(node: RemoteNavNode): NavMenuItem {
|
|||||||
|
|
||||||
/** 静态回退菜单:后台未配置时使用 */
|
/** 静态回退菜单:后台未配置时使用 */
|
||||||
const fallbackMenus = (): NavMenuItem[] =>
|
const fallbackMenus = (): NavMenuItem[] =>
|
||||||
navItems.map((item) => ({
|
navItems.map((item) => {
|
||||||
key: `static-${item.path}`,
|
const menuItem: NavMenuItem = {
|
||||||
label: item.label,
|
key: `static-${item.path}`,
|
||||||
path: item.path,
|
label: item.label,
|
||||||
external: isExternalPath(item.path),
|
path: item.path,
|
||||||
children: [],
|
external: isExternalPath(item.path),
|
||||||
}));
|
children: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新闻中心:默认把「新闻中心」下的分类作为二级菜单(鼠标悬停展开)
|
||||||
|
if (item.path === "/news") {
|
||||||
|
menuItem.children = sections.news.categories.map((cat) => ({
|
||||||
|
key: `static-news-${cat.slug}`,
|
||||||
|
label: cat.name,
|
||||||
|
path: `/news/category/${cat.slug}`,
|
||||||
|
external: false,
|
||||||
|
children: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return menuItem;
|
||||||
|
});
|
||||||
|
|
||||||
/** 页头导航菜单(应用启动时由 loadNavMenus 拉取,初始为静态回退) */
|
/** 页头导航菜单(应用启动时由 loadNavMenus 拉取,初始为静态回退) */
|
||||||
export const navMenus = ref<NavMenuItem[]>(fallbackMenus());
|
export const navMenus = ref<NavMenuItem[]>(fallbackMenus());
|
||||||
|
|||||||
+29
-26
@@ -4,9 +4,6 @@ import { siteInfo } from "@/config/site"; // 站点设置(站点名称 {yz:sit
|
|||||||
|
|
||||||
const DefaultLayout = () => import("../layout/DefaultLayout.vue");
|
const DefaultLayout = () => import("../layout/DefaultLayout.vue");
|
||||||
const HomeView = () => import("../views/home/index.vue");
|
const HomeView = () => import("../views/home/index.vue");
|
||||||
const ListView = () => import("../views/list.vue");
|
|
||||||
const DetailView = () => import("../views/detail.vue");
|
|
||||||
const SingleView = () => import("../views/single.vue");
|
|
||||||
const NotFoundView = () => import("../views/not-found.vue");
|
const NotFoundView = () => import("../views/not-found.vue");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,19 +30,19 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "news",
|
path: "news",
|
||||||
name: "News",
|
name: "News",
|
||||||
component: ListView,
|
component: () => import("../views/news/index.vue"),
|
||||||
meta: { title: "新闻中心" },
|
meta: { title: "新闻中心" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "news/category/:slug",
|
path: "news/category/:slug",
|
||||||
name: "NewsCategory",
|
name: "NewsCategory",
|
||||||
component: ListView,
|
component: () => import("../views/news/index.vue"),
|
||||||
meta: { title: "新闻中心" },
|
meta: { title: "新闻中心" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "news/detail/:slug",
|
path: "news/detail/:slug",
|
||||||
name: "NewsDetail",
|
name: "NewsDetail",
|
||||||
component: DetailView,
|
component: () => import("../views/news/detail.vue"),
|
||||||
meta: { title: "新闻详情" },
|
meta: { title: "新闻详情" },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -53,19 +50,19 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "products",
|
path: "products",
|
||||||
name: "Products",
|
name: "Products",
|
||||||
component: ListView,
|
component: () => import("../views/products/index.vue"),
|
||||||
meta: { title: "产品中心" },
|
meta: { title: "产品中心" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "products/category/:slug",
|
path: "products/category/:slug",
|
||||||
name: "ProductCategory",
|
name: "ProductCategory",
|
||||||
component: ListView,
|
component: () => import("../views/products/index.vue"),
|
||||||
meta: { title: "产品中心" },
|
meta: { title: "产品中心" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "products/detail/:slug",
|
path: "products/detail/:slug",
|
||||||
name: "ProductDetail",
|
name: "ProductDetail",
|
||||||
component: DetailView,
|
component: () => import("../views/products/detail.vue"),
|
||||||
meta: { title: "产品详情" },
|
meta: { title: "产品详情" },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -73,19 +70,19 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "solutions",
|
path: "solutions",
|
||||||
name: "Solutions",
|
name: "Solutions",
|
||||||
component: ListView,
|
component: () => import("../views/solutions/index.vue"),
|
||||||
meta: { title: "解决方案" },
|
meta: { title: "解决方案" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "solutions/category/:slug",
|
path: "solutions/category/:slug",
|
||||||
name: "SolutionCategory",
|
name: "SolutionCategory",
|
||||||
component: ListView,
|
component: () => import("../views/solutions/index.vue"),
|
||||||
meta: { title: "解决方案" },
|
meta: { title: "解决方案" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "solutions/detail/:slug",
|
path: "solutions/detail/:slug",
|
||||||
name: "SolutionDetail",
|
name: "SolutionDetail",
|
||||||
component: DetailView,
|
component: () => import("../views/solutions/detail.vue"),
|
||||||
meta: { title: "方案详情" },
|
meta: { title: "方案详情" },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -93,19 +90,19 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "cases",
|
path: "cases",
|
||||||
name: "Cases",
|
name: "Cases",
|
||||||
component: ListView,
|
component: () => import("../views/cases/index.vue"),
|
||||||
meta: { title: "客户案例" },
|
meta: { title: "客户案例" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "cases/category/:slug",
|
path: "cases/category/:slug",
|
||||||
name: "CaseCategory",
|
name: "CaseCategory",
|
||||||
component: ListView,
|
component: () => import("../views/cases/index.vue"),
|
||||||
meta: { title: "客户案例" },
|
meta: { title: "客户案例" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "cases/detail/:slug",
|
path: "cases/detail/:slug",
|
||||||
name: "CaseDetail",
|
name: "CaseDetail",
|
||||||
component: DetailView,
|
component: () => import("../views/cases/detail.vue"),
|
||||||
meta: { title: "案例详情" },
|
meta: { title: "案例详情" },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -113,19 +110,19 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "events",
|
path: "events",
|
||||||
name: "Events",
|
name: "Events",
|
||||||
component: ListView,
|
component: () => import("../views/events/index.vue"),
|
||||||
meta: { title: "市场活动" },
|
meta: { title: "市场活动" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "events/category/:slug",
|
path: "events/category/:slug",
|
||||||
name: "EventCategory",
|
name: "EventCategory",
|
||||||
component: ListView,
|
component: () => import("../views/events/index.vue"),
|
||||||
meta: { title: "市场活动" },
|
meta: { title: "市场活动" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "events/detail/:slug",
|
path: "events/detail/:slug",
|
||||||
name: "EventDetail",
|
name: "EventDetail",
|
||||||
component: DetailView,
|
component: () => import("../views/events/detail.vue"),
|
||||||
meta: { title: "活动详情" },
|
meta: { title: "活动详情" },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -133,43 +130,49 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "about",
|
path: "about",
|
||||||
name: "About",
|
name: "About",
|
||||||
component: SingleView,
|
component: () => import("../views/about.vue"),
|
||||||
meta: { title: "关于云泽" },
|
meta: { title: "关于云泽" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "careers",
|
path: "careers",
|
||||||
name: "Careers",
|
name: "Careers",
|
||||||
component: SingleView,
|
component: () => import("../views/careers.vue"),
|
||||||
meta: { title: "加入我们" },
|
meta: { title: "加入我们" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "partners",
|
path: "partners",
|
||||||
name: "Partners",
|
name: "Partners",
|
||||||
component: SingleView,
|
component: () => import("../views/partners.vue"),
|
||||||
meta: { title: "合作伙伴" },
|
meta: { title: "合作伙伴" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "contact",
|
path: "contact",
|
||||||
name: "Contact",
|
name: "Contact",
|
||||||
component: SingleView,
|
component: () => import("../views/contact.vue"),
|
||||||
meta: { title: "联系我们" },
|
meta: { title: "联系我们" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "support",
|
path: "support",
|
||||||
name: "Support",
|
name: "Support",
|
||||||
component: SingleView,
|
component: () => import("../views/support/index.vue"),
|
||||||
meta: { title: "客户支持中心" },
|
meta: { title: "客户支持中心" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "support/:slug",
|
path: "support/:slug",
|
||||||
name: "SupportDetail",
|
name: "SupportDetail",
|
||||||
component: SingleView,
|
component: () => import("../views/support/detail.vue"),
|
||||||
meta: { title: "客户支持" },
|
meta: { title: "客户支持" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "policy/:slug",
|
path: "policy",
|
||||||
name: "Policy",
|
name: "Policy",
|
||||||
component: SingleView,
|
component: () => import("../views/policy/index.vue"),
|
||||||
|
meta: { title: "政策条款" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "policy/:slug",
|
||||||
|
name: "PolicyDetail",
|
||||||
|
component: () => import("../views/policy/detail.vue"),
|
||||||
meta: { title: "政策条款" },
|
meta: { title: "政策条款" },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SinglePage from "@/views/common/SinglePage.vue";
|
||||||
|
defineOptions({ name: "AboutPage" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SinglePage from "@/views/common/SinglePage.vue";
|
||||||
|
defineOptions({ name: "CareersPage" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DetailPage from "@/views/common/DetailPage.vue";
|
||||||
|
defineOptions({ name: "CasesDetail" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DetailPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ListPage from "@/views/common/ListPage.vue";
|
||||||
|
defineOptions({ name: "CasesList" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SinglePage from "@/views/common/SinglePage.vue";
|
||||||
|
defineOptions({ name: "ContactPage" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DetailPage from "@/views/common/DetailPage.vue";
|
||||||
|
defineOptions({ name: "EventsDetail" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DetailPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ListPage from "@/views/common/ListPage.vue";
|
||||||
|
defineOptions({ name: "EventsList" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListPage />
|
||||||
|
</template>
|
||||||
@@ -24,7 +24,7 @@ import { siteInfo } from "@/config/site";
|
|||||||
|
|
||||||
<div class="banner__meta">
|
<div class="banner__meta">
|
||||||
<div class="banner__stat">
|
<div class="banner__stat">
|
||||||
<strong>12 年</strong>
|
<strong>8 年</strong>
|
||||||
<span class="banner__stat-label">行业深耕</span>
|
<span class="banner__stat-label">行业深耕</span>
|
||||||
<em>服务 1000+ 企业客户</em>
|
<em>服务 1000+ 企业客户</em>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,18 +33,10 @@ import { siteInfo } from "@/config/site";
|
|||||||
|
|
||||||
<div class="banner__stat banner__stat--contact">
|
<div class="banner__stat banner__stat--contact">
|
||||||
<span class="banner__stat-icon" aria-hidden="true">
|
<span class="banner__stat-icon" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg t="1789744335345" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1660" width="200" height="200"><path d="M869.485714 627.657143c-18.285714-58.742857-39.314286-108.114286-71.657143-188.914286C802.857143 226.514286 714.628571 54.857143 511.428571 54.857143 305.942857 54.857143 219.657143 229.942857 225.142857 438.742857c-32.457143 80.914286-53.371429 129.942857-71.657143 188.914286-38.857143 125.142857-26.285714 176.914286-16.685714 178.057143 20.571429 2.514286 80.114286-94.171429 80.114286-94.171429 0 56 28.8 129.028571 91.2 181.714286-30.171429 9.257143-97.942857 34.171429-81.828572 61.485714 13.028571 22.057143 224.228571 14.057143 285.142857 7.2 60.914286 6.857143 272.114286 14.857143 285.142858-7.2 16.114286-27.2-51.771429-52.228571-81.828572-61.485714 62.4-52.8 91.2-125.828571 91.2-181.714286 0 0 59.542857 96.685714 80.114286 94.171429 9.714286-1.257143 22.285714-53.028571-16.571429-178.057143z" p-id="1661" fill="#1296db"></path></svg>
|
||||||
<path
|
|
||||||
d="M6.6 3.5h2.6l1.5 3.7-1.9 1.3a12.4 12.4 0 0 0 5.7 5.7l1.3-1.9 3.7 1.5v2.6a2 2 0 0 1-2.2 2C10.7 17.7 6.3 13.3 4.6 5.7a2 2 0 0 1 2-2.2Z"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.6"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<strong>400-000-0000</strong>
|
<strong>1126170391</strong>
|
||||||
<span class="banner__stat-label">售前咨询专线</span>
|
<span class="banner__stat-label">项目总群</span>
|
||||||
<em>工作日 9:00 - 18:00</em>
|
<em>工作日 9:00 - 18:00</em>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,53 +1,57 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppIcon from "@/components/AppIcon.vue";
|
import { ref, onMounted } from 'vue';
|
||||||
|
|
||||||
interface ScheduleItem {
|
interface UpgradeItem {
|
||||||
range: string;
|
id: number;
|
||||||
period: string;
|
update_date: string;
|
||||||
name: string;
|
title: string;
|
||||||
desc: string;
|
content: string;
|
||||||
|
create_time: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedules: ScheduleItem[] = [
|
const upgradeList = ref<UpgradeItem[]>([]);
|
||||||
{
|
const loading = ref(false);
|
||||||
range: "09.10 ~ 09.30",
|
|
||||||
period: "2026 年 9 月",
|
// 获取平台更新内容列表
|
||||||
name: "数据中台 V3.0 全量开放试用",
|
const fetchUpgradeList = async () => {
|
||||||
desc: "面向全部客户开放试用,体验实时计算与指标管理能力。",
|
loading.value = true;
|
||||||
},
|
try {
|
||||||
{
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:9000';
|
||||||
range: "10.18 ~ 10.19",
|
const response = await fetch(`${API_BASE_URL}/platform/api/upgrade/list?limit=3`);
|
||||||
period: "2026 年 10 月",
|
const result = await response.json();
|
||||||
name: "云泽数智大会 · 上海站",
|
if (result.code === 200 && result.data) {
|
||||||
desc: "30 余位行业客户分享落地实践,现场设产品体验区。",
|
upgradeList.value = result.data;
|
||||||
},
|
}
|
||||||
{
|
} catch (error) {
|
||||||
range: "11.05 ~ 11.28",
|
console.error('获取更新内容失败:', error);
|
||||||
period: "2026 年 11 月",
|
} finally {
|
||||||
name: "全国合作伙伴城市巡展",
|
loading.value = false;
|
||||||
desc: "覆盖 12 座城市,面向合作伙伴开放产品与政策解读。",
|
}
|
||||||
},
|
};
|
||||||
];
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchUpgradeList();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="schedule">
|
<section class="schedule">
|
||||||
<div class="container schedule__inner">
|
<div class="container schedule__inner">
|
||||||
<!-- 活动时间轴 -->
|
<!-- 平台更新时间轴 -->
|
||||||
<div class="schedule__panel">
|
<div class="schedule__panel">
|
||||||
<div class="schedule__head">
|
<div class="schedule__head">
|
||||||
<h2>近期活动与发布节奏</h2>
|
<h2>近期平台更新内容</h2>
|
||||||
<p>2026 年 · 活动日历</p>
|
<p>Platform Updates</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="timeline">
|
<ul v-if="!loading" class="timeline">
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in schedules"
|
v-for="item in upgradeList"
|
||||||
:key="item.range"
|
:key="item.id"
|
||||||
class="timeline__item"
|
class="timeline__item"
|
||||||
:class="{ 'is-next': index === 0 }"
|
:class="{ 'is-next': item.id === upgradeList[0]?.id }"
|
||||||
>
|
>
|
||||||
<h3 class="timeline__range">{{ item.range }}</h3>
|
<h3 class="timeline__date">{{ item.update_date }}</h3>
|
||||||
|
|
||||||
<!-- 节点轨道:横线由 CSS 绘制,节点天然居中 -->
|
<!-- 节点轨道:横线由 CSS 绘制,节点天然居中 -->
|
||||||
<span class="timeline__rail" aria-hidden="true">
|
<span class="timeline__rail" aria-hidden="true">
|
||||||
@@ -55,27 +59,33 @@ const schedules: ScheduleItem[] = [
|
|||||||
<i class="timeline__axis"></i>
|
<i class="timeline__axis"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<p class="timeline__period">{{ item.period }}</p>
|
<p class="timeline__name">{{ item.title }}</p>
|
||||||
<p class="timeline__name">{{ item.name }}</p>
|
<p class="timeline__desc" v-html="item.content"></p>
|
||||||
<p class="timeline__desc">{{ item.desc }}</p>
|
</li>
|
||||||
|
<li v-if="upgradeList.length === 0" class="timeline__empty">
|
||||||
|
暂无更新内容
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div v-else class="timeline__loading">
|
||||||
|
<el-loading-spinner></el-loading-spinner>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 活动入口 -->
|
<!-- 管理入口 -->
|
||||||
<RouterLink to="/events" class="schedule-card">
|
<!-- <RouterLink to="/platform/softwareupgrade" class="schedule-card">
|
||||||
<span class="schedule-card__icon">
|
<span class="schedule-card__icon">
|
||||||
<AppIcon name="calendar" :size="28" :stroke-width="1.5" />
|
<AppIcon name="update" :size="28" :stroke-width="1.5" />
|
||||||
</span>
|
</span>
|
||||||
<div class="schedule-card__text">
|
<div class="schedule-card__text">
|
||||||
<strong>市场活动</strong>
|
<strong>更新管理</strong>
|
||||||
<em>参展、发布会与公开课,全年安排一站查看。</em>
|
<em>查看和管理平台更新记录</em>
|
||||||
</div>
|
</div>
|
||||||
<span class="schedule-card__more">
|
<span class="schedule-card__more">
|
||||||
查看全部活动
|
进入管理后台
|
||||||
<AppIcon name="arrow-right" :size="16" />
|
<AppIcon name="arrow-right" :size="16" />
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink> -->
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -108,7 +118,7 @@ const schedules: ScheduleItem[] = [
|
|||||||
&__inner {
|
&__inner {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 360px;
|
/* grid-template-columns: minmax(0, 1fr) 360px; */
|
||||||
gap: 26px;
|
gap: 26px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@@ -157,7 +167,7 @@ const schedules: ScheduleItem[] = [
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__range {
|
&__date {
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 36px;
|
line-height: 36px;
|
||||||
@@ -213,6 +223,15 @@ const schedules: ScheduleItem[] = [
|
|||||||
color: @ink-400;
|
color: @ink-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 25px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 36px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: @ink-900;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -237,6 +256,26 @@ const schedules: ScheduleItem[] = [
|
|||||||
&__item:hover &__name {
|
&__item:hover &__name {
|
||||||
color: @blue-600;
|
color: @blue-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: @ink-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: @ink-500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- 活动入口卡 ---------------- */
|
/* ---------------- 活动入口卡 ---------------- */
|
||||||
@@ -350,6 +389,11 @@ const schedules: ScheduleItem[] = [
|
|||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 21px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
&__axis {
|
&__axis {
|
||||||
margin-right: -34px;
|
margin-right: -34px;
|
||||||
}
|
}
|
||||||
@@ -427,6 +471,11 @@ const schedules: ScheduleItem[] = [
|
|||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
&__desc {
|
&__desc {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DetailPage from "@/views/common/DetailPage.vue";
|
||||||
|
defineOptions({ name: "NewsDetail" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DetailPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ListPage from "@/views/common/ListPage.vue";
|
||||||
|
defineOptions({ name: "NewsList" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SinglePage from "@/views/common/SinglePage.vue";
|
||||||
|
defineOptions({ name: "PartnersPage" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SinglePage from "@/views/common/SinglePage.vue";
|
||||||
|
defineOptions({ name: "PolicyDetail" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SinglePage from "@/views/common/SinglePage.vue";
|
||||||
|
defineOptions({ name: "PolicyList" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DetailPage from "@/views/common/DetailPage.vue";
|
||||||
|
defineOptions({ name: "ProductsDetail" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DetailPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ListPage from "@/views/common/ListPage.vue";
|
||||||
|
defineOptions({ name: "ProductsList" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DetailPage from "@/views/common/DetailPage.vue";
|
||||||
|
defineOptions({ name: "SolutionsDetail" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DetailPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ListPage from "@/views/common/ListPage.vue";
|
||||||
|
defineOptions({ name: "SolutionsList" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ListPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SinglePage from "@/views/common/SinglePage.vue";
|
||||||
|
defineOptions({ name: "SupportDetail" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SinglePage from "@/views/common/SinglePage.vue";
|
||||||
|
defineOptions({ name: "SupportList" });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePage />
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user