做统一认证登录
This commit is contained in:
@@ -69,3 +69,5 @@ go/conf
|
||||
.mimocode/package.json
|
||||
.mimocode/package-lock.json
|
||||
.mimocode/bun.lock
|
||||
|
||||
docs/sql/
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import { convertMenusToRoutes } from "./dynamicRoutes";
|
||||
import { isSSOEnabled, redirectToAuthorize } from "@/utils/authClient";
|
||||
|
||||
// 静态子路由:需要在 Main 框架内显示的页面
|
||||
const staticMainChildren = [
|
||||
@@ -146,6 +147,13 @@ const staticRoutes = [
|
||||
component: () => import("@/views/login/forget.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
// 统一认证中心回跳页(接收 code 换取令牌,页面自身不展示业务内容)
|
||||
{
|
||||
path: "/auth/callback",
|
||||
name: "AuthCallback",
|
||||
component: () => import("@/views/auth/callback.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: "/home",
|
||||
name: "Home",
|
||||
@@ -304,7 +312,7 @@ function findFirstValidRoute(routes) {
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem("token");
|
||||
const publicPaths = ["/login", "/register", "/forget"];
|
||||
const publicPaths = ["/login", "/register", "/forget", "/auth/callback"];
|
||||
|
||||
if (publicPaths.includes(to.path)) {
|
||||
if (token) {
|
||||
@@ -319,6 +327,11 @@ router.beforeEach(async (to, from, next) => {
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
// 统一认证模式:跳认证中心登录(未开启时走原有本地登录页)
|
||||
if (isSSOEnabled()) {
|
||||
await redirectToAuthorize();
|
||||
return;
|
||||
}
|
||||
next({ path: "/login", query: { redirect: to.path } });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { getCurrentUser } from '@/api/login'
|
||||
import { isSSOEnabled, logoutSSO } from '@/utils/authClient'
|
||||
|
||||
// 用户信息类型
|
||||
const defaultUser = {
|
||||
@@ -93,6 +94,10 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
Object.assign(user, defaultUser)
|
||||
localStorage.removeItem('token')
|
||||
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 { isSSOEnabled, refreshAccessToken, clearTokens } from '@/utils/authClient';
|
||||
|
||||
// 获取API基础URL,添加调试信息
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
|
||||
@@ -40,13 +41,27 @@ service.interceptors.response.use(
|
||||
response => {
|
||||
return response.data;
|
||||
},
|
||||
error => {
|
||||
async error => {
|
||||
if (error.response) {
|
||||
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
|
||||
// 避免页面只显示 "Request failed with status code xxx"
|
||||
const bizMsg = error.response.data?.msg || error.response.data?.message;
|
||||
switch (error.response.status) {
|
||||
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('未授权,请重新登录');
|
||||
localStorage.removeItem('token');
|
||||
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 (rememberMe.value) {
|
||||
localStorage.setItem("loginAccount", account.value);
|
||||
localStorage.setItem("loginTenantName", tenant_name.value);
|
||||
localStorage.setItem("loginPassword", password.value);
|
||||
localStorage.setItem("loginRememberMe", "true");
|
||||
} else {
|
||||
localStorage.removeItem("loginAccount");
|
||||
localStorage.removeItem("loginTenantName");
|
||||
localStorage.removeItem("loginPassword");
|
||||
localStorage.setItem("loginRememberMe", "false");
|
||||
}
|
||||
localStorage.removeItem("loginPassword");
|
||||
|
||||
authStore.setLoginInfo({ ...res.data, tenant_name: tenant_name.value });
|
||||
|
||||
@@ -327,17 +327,17 @@ const startGeetest4 = async () => {
|
||||
});
|
||||
|
||||
if (loginRes && loginRes.code === 200) {
|
||||
// 安全:只记住账号与租户名,绝不明文存储密码
|
||||
if (rememberMe.value) {
|
||||
localStorage.setItem("loginAccount", account.value);
|
||||
localStorage.setItem("loginTenantName", tenant_name.value);
|
||||
localStorage.setItem("loginPassword", password.value);
|
||||
localStorage.setItem("loginRememberMe", "true");
|
||||
} else {
|
||||
localStorage.removeItem("loginAccount");
|
||||
localStorage.removeItem("loginTenantName");
|
||||
localStorage.removeItem("loginPassword");
|
||||
localStorage.setItem("loginRememberMe", "false");
|
||||
}
|
||||
localStorage.removeItem("loginPassword");
|
||||
|
||||
authStore.setLoginInfo({ ...loginRes.data, tenant_name: tenant_name.value });
|
||||
const { useTabsStore } = await import("@/stores");
|
||||
@@ -471,7 +471,6 @@ onMounted(async () => {
|
||||
if (savedRemember === "true") {
|
||||
tenant_name.value = localStorage.getItem("loginTenantName") || "";
|
||||
account.value = localStorage.getItem("loginAccount") || "";
|
||||
password.value = localStorage.getItem("loginPassword") || "";
|
||||
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 进程需要对此目录有写权限。
|
||||
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 派生。
|
||||
# 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -427,6 +427,12 @@ func (c *BackendAdminUserController) AddUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// 双写:同步到统一认证中心(读回最新记录,确保 group_id 已落库)
|
||||
var created models.SystemTenantUser
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); e == nil {
|
||||
syncTenantUserToAuth(&created)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
@@ -549,6 +555,12 @@ func (c *BackendAdminUserController) EditUser() {
|
||||
_ = services.SetDefaultTenant(row.Uid, row.Tid)
|
||||
}
|
||||
|
||||
// 双写:同步到统一认证中心
|
||||
var edited models.SystemTenantUser
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&edited); e == nil {
|
||||
syncTenantUserToAuth(&edited)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
@@ -586,6 +598,9 @@ func (c *BackendAdminUserController) DeleteUser() {
|
||||
return
|
||||
}
|
||||
|
||||
// 双写:删除认证中心中的企业绑定
|
||||
removeTenantUserFromAuth(row)
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
@@ -642,6 +657,12 @@ func (c *BackendAdminUserController) ChangePassword() {
|
||||
return
|
||||
}
|
||||
|
||||
// 双写:同步新密码到统一认证中心
|
||||
var updated models.SystemTenantUser
|
||||
if e := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", row.ID).One(&updated); e == nil {
|
||||
syncTenantUserToAuth(&updated)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -13,11 +14,65 @@ import (
|
||||
"server/models"
|
||||
"server/pkg/passwordutil"
|
||||
"server/services"
|
||||
authsvc "server/services/auth"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// syncTenantUserToAuth 把租户用户同步到统一认证中心(双写)。
|
||||
// 同步失败只记日志,不影响平台端主流程(认证中心为新增能力)。
|
||||
func syncTenantUserToAuth(row *models.SystemTenantUser) {
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
in := authsvc.SyncTenantUserInput{
|
||||
Tid: row.Tid,
|
||||
GroupID: row.GroupID,
|
||||
OrgID: row.OrgID,
|
||||
Status: row.Status,
|
||||
IsDefault: row.IsDefault,
|
||||
}
|
||||
if row.Account != nil {
|
||||
in.Account = strings.TrimSpace(*row.Account)
|
||||
}
|
||||
if row.Name != nil {
|
||||
in.Name = strings.TrimSpace(*row.Name)
|
||||
}
|
||||
if row.Phone != nil {
|
||||
in.Phone = strings.TrimSpace(*row.Phone)
|
||||
}
|
||||
if row.Email != nil {
|
||||
in.Email = strings.TrimSpace(*row.Email)
|
||||
}
|
||||
if row.Password != nil {
|
||||
in.PasswordHash = *row.Password
|
||||
}
|
||||
if err := authsvc.SyncTenantUser(in); err != nil {
|
||||
log.Printf("[auth] 同步租户用户到认证中心失败 tid=%d account=%s: %v", row.Tid, in.Account, err)
|
||||
}
|
||||
}
|
||||
|
||||
// removeTenantUserFromAuth 删除认证中心中的企业绑定(保留身份)
|
||||
func removeTenantUserFromAuth(row *models.SystemTenantUser) {
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
account, phone, email := "", "", ""
|
||||
if row.Account != nil {
|
||||
account = strings.TrimSpace(*row.Account)
|
||||
}
|
||||
if row.Phone != nil {
|
||||
phone = strings.TrimSpace(*row.Phone)
|
||||
}
|
||||
if row.Email != nil {
|
||||
email = strings.TrimSpace(*row.Email)
|
||||
}
|
||||
if err := authsvc.RemoveTenantUser(row.Tid, account, phone, email); err != nil {
|
||||
log.Printf("[auth] 删除认证中心企业绑定失败 tid=%d account=%s: %v", row.Tid, account, err)
|
||||
}
|
||||
}
|
||||
|
||||
// PlatformTenantUserController 平台租户用户绑定管理
|
||||
type PlatformTenantUserController struct {
|
||||
beego.Controller
|
||||
@@ -231,6 +286,12 @@ func (c *PlatformTenantUserController) CreateTenantUser() {
|
||||
}
|
||||
}
|
||||
|
||||
// 双写:同步到统一认证中心(读回最新记录,确保 group_id/org_id 已落库)
|
||||
var created models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&created); err == nil {
|
||||
syncTenantUserToAuth(&created)
|
||||
}
|
||||
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
@@ -308,6 +369,12 @@ func (c *PlatformTenantUserController) EditTenantUser() {
|
||||
_ = 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()
|
||||
}
|
||||
@@ -322,12 +389,19 @@ func (c *PlatformTenantUserController) DeleteTenantUser() {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
+153
-30
@@ -1,62 +1,181 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/server/web"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
"github.com/beego/beego/v2/server/web/context"
|
||||
)
|
||||
|
||||
// JWTAuthMiddleware JWT认证中间件
|
||||
func JWTAuthMiddleware() web.FilterFunc {
|
||||
return func(ctx *context.Context) {
|
||||
// 跳过登录相关的路由
|
||||
if strings.HasPrefix(ctx.Request.RequestURI, "/api/login") ||
|
||||
strings.HasPrefix(ctx.Request.RequestURI, "/api/reset-password") {
|
||||
// 鉴权开关(app.conf: auth_enforce)
|
||||
// - off :完全不启用(等同改造前的行为)
|
||||
// - warn :观察模式,只记录「无 token / token 无效」的接口,不拦截(默认,零风险上线)
|
||||
// - on :真正拦截并返回 401
|
||||
//
|
||||
// 上线建议:先 warn 跑一段时间,用日志核对白名单是否遗漏,确认无误后再切 on。
|
||||
const (
|
||||
AuthModeOff = "off"
|
||||
AuthModeWarn = "warn"
|
||||
AuthModeOn = "on"
|
||||
)
|
||||
|
||||
// protectedPrefixes 需要鉴权的业务前缀。
|
||||
// 仅保护业务 API:官网(/ /site/ /index/…)、开放 API(/api/…)、
|
||||
// 静态资源与 ACME 验证路径均不在其中,避免误伤公开接口。
|
||||
var protectedPrefixes = []string{"/backend/", "/platform/", "/app/"}
|
||||
|
||||
// authWhitelist 受保护前缀内无需鉴权的路径(前缀匹配)
|
||||
var authWhitelist = []string{
|
||||
// 统一认证中心(P1 上线)
|
||||
"/auth/",
|
||||
|
||||
// backend 登录/注册/找回密码
|
||||
"/backend/login",
|
||||
"/backend/sendLoginCode",
|
||||
"/backend/register",
|
||||
"/backend/sendRegisterCode",
|
||||
"/backend/resetPassword",
|
||||
"/backend/sendResetCode",
|
||||
"/backend/verifyAccount",
|
||||
"/backend/logout",
|
||||
|
||||
// platform 登录/找回密码
|
||||
"/platform/login",
|
||||
"/platform/sendLoginCode",
|
||||
"/platform/resetPassword",
|
||||
"/platform/logout",
|
||||
// 平台开放接口(客户端更新通知,无需登录)
|
||||
"/platform/api/",
|
||||
|
||||
// app 登录/注册/找回密码
|
||||
"/app/login",
|
||||
"/app/sendLoginCode",
|
||||
"/app/register",
|
||||
"/app/sendRegisterCode",
|
||||
"/app/resetPassword",
|
||||
"/app/sendResetCode",
|
||||
"/app/verifyAccount",
|
||||
"/app/logout",
|
||||
}
|
||||
|
||||
var (
|
||||
modeOnce sync.Once
|
||||
modeVal = AuthModeWarn
|
||||
warnSeen sync.Map // 观察模式下按 path 去重,避免日志刷屏
|
||||
)
|
||||
|
||||
func authMode() string {
|
||||
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 == "" {
|
||||
ctx.Output.SetStatus(401)
|
||||
ctx.Output.JSON(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "未提供认证信息",
|
||||
}, false, false)
|
||||
reject(401, "未提供认证信息")
|
||||
return
|
||||
}
|
||||
|
||||
// 按空格分割
|
||||
authParts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(authParts) == 2 && authParts[0] == "Bearer") {
|
||||
ctx.Output.SetStatus(401)
|
||||
ctx.Output.JSON(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "认证信息格式错误",
|
||||
}, false, false)
|
||||
reject(401, "认证信息格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 解析token
|
||||
claims, err := jwtutil.ParseToken(authParts[1])
|
||||
if err != nil {
|
||||
// 处理各种错误情况
|
||||
ctx.Output.SetStatus(401)
|
||||
switch err.Error() {
|
||||
case "token is expired":
|
||||
ctx.Output.JSON(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "token已过期",
|
||||
}, false, false)
|
||||
default:
|
||||
ctx.Output.JSON(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "无效的token",
|
||||
}, false, false)
|
||||
message := "无效的token"
|
||||
if strings.Contains(err.Error(), "expired") {
|
||||
message = "token已过期"
|
||||
}
|
||||
reject(401, message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -71,5 +190,9 @@ func JWTAuthMiddleware() web.FilterFunc {
|
||||
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(TenantCrmPayback),
|
||||
new(TenantCrmPaybackRecord),
|
||||
// 平台更新通知(docs/sql/platform_upgrade.sql)
|
||||
new(PlatformUpgradeNotice),
|
||||
new(ErpAccountSet),
|
||||
new(ErpNormalSetting),
|
||||
new(ErpCompanyContact),
|
||||
@@ -176,6 +178,19 @@ func Init(_ string) {
|
||||
new(WechatMpConfig),
|
||||
new(WechatMpFollower),
|
||||
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
|
||||
@@ -192,5 +207,3 @@ func Init(_ string) {
|
||||
// 新建与调整走 SQL:docs/sql/update_role_system.sql,代码不做自动补建。
|
||||
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"
|
||||
}
|
||||
+283
-25
@@ -1,63 +1,321 @@
|
||||
package jwtutil
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// 密钥(后续可从配置中读取)
|
||||
var secret = []byte("yunzer_jwt_secret_key")
|
||||
// 算法常量
|
||||
const (
|
||||
AlgHS256 = "HS256"
|
||||
AlgRS256 = "RS256"
|
||||
)
|
||||
|
||||
// Claims 定义JWT的claims结构
|
||||
// DefaultKid 默认密钥 ID。历史 token 未携带 kid,统一按此 ID 处理。
|
||||
const DefaultKid = "default"
|
||||
|
||||
// legacySecret 兼容用的内置密钥:仅用于解析历史已签发的 token。
|
||||
// 生产环境必须在 app.conf 中配置 jwt_secret,否则会一直使用该固定值。
|
||||
const legacySecret = "yunzer_jwt_secret_key"
|
||||
|
||||
// Claims JWT 载荷。旧字段保持不变,新增字段均为 omitempty,
|
||||
// 历史 token 解析后为零值,不影响现有 78 处解析点。
|
||||
type Claims struct {
|
||||
UserID int `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
TenantId int `json:"tenant_id"` // 租户ID
|
||||
UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等
|
||||
|
||||
// ---- 统一认证中心(OIDC)扩展字段 ----
|
||||
ClientID string `json:"client_id,omitempty"` // 接入应用 client_id,即 aud 的业务标识
|
||||
Sid string `json:"sid,omitempty"` // 会话ID,用于单点登出/踢下线
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Amr string `json:"amr,omitempty"` // 认证方式:pwd/sms/otp/wx/...
|
||||
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken 生成JWT token
|
||||
func GenerateToken(userID int, username string, tenantId int, userType string) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
// ---------------------------------------------------------------- 密钥管理
|
||||
|
||||
var (
|
||||
keyOnce sync.Once
|
||||
hsKeys map[string][]byte // kid -> HMAC 密钥
|
||||
rsaPriv *rsa.PrivateKey // RS256 签名
|
||||
rsaPub *rsa.PublicKey // RS256 验签
|
||||
rsaKid string // RS256 密钥 ID
|
||||
issuerVal string
|
||||
)
|
||||
|
||||
func loadKeys() {
|
||||
hsKeys = map[string][]byte{DefaultKid: []byte(legacySecret)}
|
||||
|
||||
// 主密钥(覆盖内置默认值)
|
||||
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(expirationTime),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
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)
|
||||
tokenString, err := token.SignedString(secret)
|
||||
return tokenString, err
|
||||
return token.SignedString(hsKeys[DefaultKid])
|
||||
}
|
||||
|
||||
// ParseToken 解析JWT token
|
||||
// ---------------------------------------------------------------- 解析
|
||||
|
||||
// ParseToken 解析并校验 JWT。自动识别 HS256 / RS256,函数签名与旧版一致。
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
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
|
||||
})
|
||||
|
||||
_, err := jwt.ParseWithClaims(tokenString, claims, keyFunc,
|
||||
jwt.WithValidMethods([]string{AlgHS256, AlgRS256}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
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 不应为空")
|
||||
}
|
||||
}
|
||||
@@ -3,34 +3,125 @@ package passwordutil
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// 算法标识。
|
||||
// 历史数据用 legacy(sha256 单轮,无迭代拉伸,抗 GPU 爆破能力弱);
|
||||
// 新密码统一用 argon2id,登录成功检测到 legacy 时应重新哈希升级。
|
||||
const (
|
||||
AlgoArgon2id = "argon2id"
|
||||
AlgoLegacy = "legacy"
|
||||
)
|
||||
|
||||
// argon2id 参数(OWASP 推荐起步配置:64MB / 3 轮 / 并行 2)
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // 单位 KB,即 64MB
|
||||
argonThreads = 2
|
||||
argonKeyLen = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
// legacy 兼容参数
|
||||
const (
|
||||
saltBytes = 16
|
||||
separator = "$"
|
||||
hashLength = 64 // sha256 hex length
|
||||
)
|
||||
|
||||
// Hash 生成 salt+hash 的存储串,格式:salt$hash(均为 hex)
|
||||
// Hash 使用当前默认算法(argon2id)生成密码存储串。
|
||||
//
|
||||
// 返回 PHC 标准格式(盐与参数内联,无需独立 salt 列):
|
||||
//
|
||||
// $argon2id$v=19$m=65536,t=3,p=2$<base64(salt)>$<base64(hash)>
|
||||
//
|
||||
// 注意:函数签名与旧版一致,全部调用点无需改动即可切换到新算法。
|
||||
func Hash(plain string) (string, error) {
|
||||
plain = strings.TrimSpace(plain)
|
||||
if plain == "" {
|
||||
return "", errors.New("password 不能为空")
|
||||
}
|
||||
salt := make([]byte, saltBytes)
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
saltHex := hex.EncodeToString(salt)
|
||||
hashHex := hashHex(saltHex, plain)
|
||||
return saltHex + separator + hashHex, nil
|
||||
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 校验存储串(salt$hash)是否匹配输入明文密码。
|
||||
// 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 == "" {
|
||||
@@ -45,11 +136,14 @@ func Verify(stored, plain string) bool {
|
||||
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)路由。
|
||||
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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"server/middleware"
|
||||
"server/routers/api"
|
||||
"server/routers/app"
|
||||
authrouter "server/routers/auth"
|
||||
"server/routers/backend"
|
||||
"server/routers/index"
|
||||
"server/routers/platform"
|
||||
@@ -33,6 +34,12 @@ func init() {
|
||||
}
|
||||
})
|
||||
|
||||
// 全局 JWT 鉴权:解析 token 并写入上下文(userId/tenantId/userType/...)。
|
||||
// 模式由 app.conf 的 auth_enforce 控制:
|
||||
// warn(默认,观察模式,只记录不拦截)→ on(真正拦截)→ off(紧急关闭)
|
||||
// 放在操作日志之前注册,保证操作日志能取到 userId。
|
||||
beego.InsertFilter("*", beego.BeforeRouter, middleware.JWTAuthMiddleware())
|
||||
|
||||
// 全局操作日志:请求开始采集并异步写入
|
||||
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
|
||||
|
||||
@@ -40,6 +47,9 @@ func init() {
|
||||
// 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。
|
||||
beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve")
|
||||
|
||||
// 统一认证中心(UAC):所有运行模式都必须注册,否则对应模式下无法登录
|
||||
authrouter.Register()
|
||||
|
||||
// 根据运行模式选择要注册的路由组
|
||||
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
|
||||
mode := os.Getenv("APP_MODE")
|
||||
|
||||
@@ -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/pkg/passwordutil"
|
||||
authsvc "server/services/auth"
|
||||
)
|
||||
|
||||
// resetCodeItem 存储找回密码的验证码
|
||||
@@ -250,9 +251,31 @@ func ResetPassword(tenantName, account, phone, smsCode, newPassword, confirmPass
|
||||
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
|
||||
}
|
||||
|
||||
// trimStrPtr 取指针字符串并去空格;nil 时返回空串
|
||||
func trimStrPtr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*p)
|
||||
}
|
||||
|
||||
// enqueueSMSTaskForPasswordReset 发送密码重置短信任务
|
||||
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) {
|
||||
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 userType = "platform"
|
||||
@@ -116,6 +123,13 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
|
||||
if tenantUser.Password == nil || !passwordutil.Verify(*tenantUser.Password, password) {
|
||||
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)
|
||||
const userType = "backend"
|
||||
@@ -144,6 +158,22 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
|
||||
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 返回登录用户信息(含角色名称)。
|
||||
func PlatformGetCurrentUser(uid uint64) (*PlatformLoginUser, error) {
|
||||
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 (rememberMe.value) {
|
||||
localStorage.setItem("loginAccount", account.value);
|
||||
localStorage.setItem("loginPassword", password.value);
|
||||
localStorage.setItem("loginRememberMe", "true");
|
||||
} else {
|
||||
localStorage.removeItem("loginAccount");
|
||||
localStorage.removeItem("loginTenantName");
|
||||
localStorage.removeItem("loginPassword");
|
||||
localStorage.setItem("loginRememberMe", "false");
|
||||
}
|
||||
localStorage.removeItem("loginPassword");
|
||||
|
||||
authStore.setLoginInfo(res.data);
|
||||
|
||||
@@ -353,16 +353,16 @@ const startGeetest4 = async () => {
|
||||
});
|
||||
|
||||
if (loginRes && loginRes.code === 200) {
|
||||
// 安全:只记住账号,绝不明文存储密码
|
||||
if (rememberMe.value) {
|
||||
localStorage.setItem("loginAccount", account.value);
|
||||
localStorage.setItem("loginPassword", password.value);
|
||||
localStorage.setItem("loginRememberMe", "true");
|
||||
} else {
|
||||
localStorage.removeItem("loginAccount");
|
||||
localStorage.removeItem("loginTenantName");
|
||||
localStorage.removeItem("loginPassword");
|
||||
localStorage.setItem("loginRememberMe", "false");
|
||||
}
|
||||
localStorage.removeItem("loginPassword");
|
||||
|
||||
authStore.setLoginInfo(loginRes.data);
|
||||
const { useTabsStore } = await import("@/stores");
|
||||
@@ -468,7 +468,7 @@ const handleRememberMeChange = async () => {
|
||||
if (rememberMe.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"请确认电脑环境是可信的,登录成功后会自动记住密码。",
|
||||
"请确认电脑环境是可信的,登录成功后会自动记住账号(不会保存密码)。",
|
||||
"安全提示",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
@@ -489,7 +489,6 @@ onMounted(() => {
|
||||
const savedRemember = localStorage.getItem("loginRememberMe");
|
||||
if (savedRemember === "true") {
|
||||
account.value = localStorage.getItem("loginAccount") || "";
|
||||
password.value = localStorage.getItem("loginPassword") || "";
|
||||
rememberMe.value = true;
|
||||
}
|
||||
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'
|
||||
|
||||
/** 账号密码登录 */
|
||||
/** 账号密码登录(旧:租户端直登) */
|
||||
export function login(data) {
|
||||
return request({
|
||||
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) {
|
||||
return request({
|
||||
|
||||
+69
-2
@@ -2,7 +2,7 @@
|
||||
* 统一请求封装:baseURL、Bearer Token、业务 code 处理。
|
||||
* 接口地址统一从 .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'
|
||||
|
||||
export { getBaseURL }
|
||||
@@ -11,6 +11,63 @@ function buildFullUrl(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
|
||||
* @returns {Promise<any>} 业务 data 字段(若无 data 则返回整包)
|
||||
@@ -35,11 +92,21 @@ export function request(options = {}) {
|
||||
data: data || {},
|
||||
header: headers,
|
||||
timeout,
|
||||
success(res) {
|
||||
async success(res) {
|
||||
const status = res.statusCode
|
||||
const body = res.data || {}
|
||||
|
||||
if (status === 401 || body.code === 401) {
|
||||
// 统一认证模式:先用 refresh_token 静默续期并重放请求,
|
||||
// 续期失败(令牌过期/被吊销/被踢下线)才真正登出。
|
||||
if (isSSOEnabled()) {
|
||||
try {
|
||||
resolve(await replayRequest(options))
|
||||
return
|
||||
} catch (e) {
|
||||
// 续期失败,继续走下面的登出流程
|
||||
}
|
||||
}
|
||||
logout()
|
||||
if (!silent) {
|
||||
uni.showToast({ title: body.msg || '请重新登录', icon: 'none' })
|
||||
|
||||
@@ -137,7 +137,9 @@ import {
|
||||
login,
|
||||
loginBySms,
|
||||
sendLoginCode,
|
||||
fetchVerifyConfig
|
||||
fetchVerifyConfig,
|
||||
loginUAC,
|
||||
switchTenant
|
||||
} from '@/api/auth.js'
|
||||
import {
|
||||
loginSuccess,
|
||||
@@ -146,7 +148,11 @@ import {
|
||||
getTenantName,
|
||||
getRememberLogin,
|
||||
saveRememberLogin,
|
||||
clearRememberLogin
|
||||
clearRememberLogin,
|
||||
isSSOEnabled,
|
||||
setAuthTokens,
|
||||
setTid,
|
||||
getToken
|
||||
} from '@/utils/auth.js'
|
||||
import { showGeetest4 } from '@/utils/geetest.js'
|
||||
|
||||
@@ -191,7 +197,6 @@ onMounted(async () => {
|
||||
rememberMe.value = true
|
||||
tenantName.value = remembered.tenantName
|
||||
username.value = remembered.account
|
||||
password.value = remembered.password
|
||||
} else {
|
||||
tenantName.value = getTenantName()
|
||||
}
|
||||
@@ -276,6 +281,84 @@ function navigateAfterLogin() {
|
||||
}, 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) {
|
||||
if (!username.value.trim()) {
|
||||
uni.showToast({ title: '请输入账号', icon: 'none' })
|
||||
@@ -311,8 +394,7 @@ async function submitAccountLogin(geetestResult = null) {
|
||||
if (rememberMe.value) {
|
||||
saveRememberLogin({
|
||||
tenantName: tenantName.value.trim(),
|
||||
account: username.value.trim(),
|
||||
password: password.value
|
||||
account: username.value.trim()
|
||||
})
|
||||
} else {
|
||||
clearRememberLogin()
|
||||
@@ -326,6 +408,16 @@ async function handleLogin() {
|
||||
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 统一认证模式:账号即手机/邮箱,无需填写租户名
|
||||
if (isSSOEnabled()) {
|
||||
submitting.value = true
|
||||
try {
|
||||
await submitSSOLogin()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!tenantName.value.trim()) {
|
||||
uni.showToast({ title: '请输入租户名称', icon: 'none' })
|
||||
return
|
||||
|
||||
@@ -39,45 +39,6 @@
|
||||
<view v-else class="schedule-list">
|
||||
<view v-for="group in groupedList" :key="group.key" class="group">
|
||||
<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 class="bottom-space" />
|
||||
|
||||
+47
-7
@@ -1,6 +1,41 @@
|
||||
const TOKEN_KEY = 'app_token'
|
||||
const USER_KEY = 'app_user'
|
||||
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_TENANT_KEY = 'app_remember_tenant'
|
||||
const REMEMBER_ACCOUNT_KEY = 'app_remember_account'
|
||||
@@ -37,6 +72,8 @@ export function isLoggedIn() {
|
||||
export function logout() {
|
||||
uni.removeStorageSync(TOKEN_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'
|
||||
}
|
||||
|
||||
/** 读取记住的账号登录信息 */
|
||||
/**
|
||||
* 读取记住的账号登录信息。
|
||||
* 安全说明:不再保存/回填密码,仅记住租户名与账号。
|
||||
*/
|
||||
export function getRememberLogin() {
|
||||
if (!isRememberLogin()) {
|
||||
return { rememberMe: false, tenantName: '', account: '', password: '' }
|
||||
return { rememberMe: false, tenantName: '', account: '' }
|
||||
}
|
||||
return {
|
||||
rememberMe: true,
|
||||
tenantName: uni.getStorageSync(REMEMBER_TENANT_KEY) || '',
|
||||
account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || '',
|
||||
password: uni.getStorageSync(REMEMBER_PASSWORD_KEY) || ''
|
||||
account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存账号登录信息(租户、账号、密码) */
|
||||
export function saveRememberLogin({ tenantName = '', account = '', password = '' } = {}) {
|
||||
/** 保存账号登录信息(租户、账号;不含密码) */
|
||||
export function saveRememberLogin({ tenantName = '', account = '' } = {}) {
|
||||
uni.setStorageSync(REMEMBER_KEY, '1')
|
||||
uni.setStorageSync(REMEMBER_TENANT_KEY, tenantName)
|
||||
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 { getJSON } from "@/api/request";
|
||||
import { navItems } from "./site";
|
||||
import { sections } from "./sections";
|
||||
|
||||
export interface NavMenuItem {
|
||||
/** v-for key:后端菜单用 nav-{id},静态回退用 static-{path} */
|
||||
@@ -53,13 +54,28 @@ function toMenuItem(node: RemoteNavNode): NavMenuItem {
|
||||
|
||||
/** 静态回退菜单:后台未配置时使用 */
|
||||
const fallbackMenus = (): NavMenuItem[] =>
|
||||
navItems.map((item) => ({
|
||||
navItems.map((item) => {
|
||||
const menuItem: NavMenuItem = {
|
||||
key: `static-${item.path}`,
|
||||
label: item.label,
|
||||
path: item.path,
|
||||
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 拉取,初始为静态回退) */
|
||||
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 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");
|
||||
|
||||
/**
|
||||
@@ -33,19 +30,19 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "news",
|
||||
name: "News",
|
||||
component: ListView,
|
||||
component: () => import("../views/news/index.vue"),
|
||||
meta: { title: "新闻中心" },
|
||||
},
|
||||
{
|
||||
path: "news/category/:slug",
|
||||
name: "NewsCategory",
|
||||
component: ListView,
|
||||
component: () => import("../views/news/index.vue"),
|
||||
meta: { title: "新闻中心" },
|
||||
},
|
||||
{
|
||||
path: "news/detail/:slug",
|
||||
name: "NewsDetail",
|
||||
component: DetailView,
|
||||
component: () => import("../views/news/detail.vue"),
|
||||
meta: { title: "新闻详情" },
|
||||
},
|
||||
|
||||
@@ -53,19 +50,19 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "products",
|
||||
name: "Products",
|
||||
component: ListView,
|
||||
component: () => import("../views/products/index.vue"),
|
||||
meta: { title: "产品中心" },
|
||||
},
|
||||
{
|
||||
path: "products/category/:slug",
|
||||
name: "ProductCategory",
|
||||
component: ListView,
|
||||
component: () => import("../views/products/index.vue"),
|
||||
meta: { title: "产品中心" },
|
||||
},
|
||||
{
|
||||
path: "products/detail/:slug",
|
||||
name: "ProductDetail",
|
||||
component: DetailView,
|
||||
component: () => import("../views/products/detail.vue"),
|
||||
meta: { title: "产品详情" },
|
||||
},
|
||||
|
||||
@@ -73,19 +70,19 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "solutions",
|
||||
name: "Solutions",
|
||||
component: ListView,
|
||||
component: () => import("../views/solutions/index.vue"),
|
||||
meta: { title: "解决方案" },
|
||||
},
|
||||
{
|
||||
path: "solutions/category/:slug",
|
||||
name: "SolutionCategory",
|
||||
component: ListView,
|
||||
component: () => import("../views/solutions/index.vue"),
|
||||
meta: { title: "解决方案" },
|
||||
},
|
||||
{
|
||||
path: "solutions/detail/:slug",
|
||||
name: "SolutionDetail",
|
||||
component: DetailView,
|
||||
component: () => import("../views/solutions/detail.vue"),
|
||||
meta: { title: "方案详情" },
|
||||
},
|
||||
|
||||
@@ -93,19 +90,19 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "cases",
|
||||
name: "Cases",
|
||||
component: ListView,
|
||||
component: () => import("../views/cases/index.vue"),
|
||||
meta: { title: "客户案例" },
|
||||
},
|
||||
{
|
||||
path: "cases/category/:slug",
|
||||
name: "CaseCategory",
|
||||
component: ListView,
|
||||
component: () => import("../views/cases/index.vue"),
|
||||
meta: { title: "客户案例" },
|
||||
},
|
||||
{
|
||||
path: "cases/detail/:slug",
|
||||
name: "CaseDetail",
|
||||
component: DetailView,
|
||||
component: () => import("../views/cases/detail.vue"),
|
||||
meta: { title: "案例详情" },
|
||||
},
|
||||
|
||||
@@ -113,19 +110,19 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "events",
|
||||
name: "Events",
|
||||
component: ListView,
|
||||
component: () => import("../views/events/index.vue"),
|
||||
meta: { title: "市场活动" },
|
||||
},
|
||||
{
|
||||
path: "events/category/:slug",
|
||||
name: "EventCategory",
|
||||
component: ListView,
|
||||
component: () => import("../views/events/index.vue"),
|
||||
meta: { title: "市场活动" },
|
||||
},
|
||||
{
|
||||
path: "events/detail/:slug",
|
||||
name: "EventDetail",
|
||||
component: DetailView,
|
||||
component: () => import("../views/events/detail.vue"),
|
||||
meta: { title: "活动详情" },
|
||||
},
|
||||
|
||||
@@ -133,43 +130,49 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "about",
|
||||
name: "About",
|
||||
component: SingleView,
|
||||
component: () => import("../views/about.vue"),
|
||||
meta: { title: "关于云泽" },
|
||||
},
|
||||
{
|
||||
path: "careers",
|
||||
name: "Careers",
|
||||
component: SingleView,
|
||||
component: () => import("../views/careers.vue"),
|
||||
meta: { title: "加入我们" },
|
||||
},
|
||||
{
|
||||
path: "partners",
|
||||
name: "Partners",
|
||||
component: SingleView,
|
||||
component: () => import("../views/partners.vue"),
|
||||
meta: { title: "合作伙伴" },
|
||||
},
|
||||
{
|
||||
path: "contact",
|
||||
name: "Contact",
|
||||
component: SingleView,
|
||||
component: () => import("../views/contact.vue"),
|
||||
meta: { title: "联系我们" },
|
||||
},
|
||||
{
|
||||
path: "support",
|
||||
name: "Support",
|
||||
component: SingleView,
|
||||
component: () => import("../views/support/index.vue"),
|
||||
meta: { title: "客户支持中心" },
|
||||
},
|
||||
{
|
||||
path: "support/:slug",
|
||||
name: "SupportDetail",
|
||||
component: SingleView,
|
||||
component: () => import("../views/support/detail.vue"),
|
||||
meta: { title: "客户支持" },
|
||||
},
|
||||
{
|
||||
path: "policy/:slug",
|
||||
path: "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: "政策条款" },
|
||||
},
|
||||
|
||||
|
||||
@@ -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__stat">
|
||||
<strong>12 年</strong>
|
||||
<strong>8 年</strong>
|
||||
<span class="banner__stat-label">行业深耕</span>
|
||||
<em>服务 1000+ 企业客户</em>
|
||||
</div>
|
||||
@@ -33,18 +33,10 @@ import { siteInfo } from "@/config/site";
|
||||
|
||||
<div class="banner__stat banner__stat--contact">
|
||||
<span class="banner__stat-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<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>
|
||||
<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>
|
||||
</span>
|
||||
<strong>400-000-0000</strong>
|
||||
<span class="banner__stat-label">售前咨询专线</span>
|
||||
<strong>1126170391</strong>
|
||||
<span class="banner__stat-label">项目总群</span>
|
||||
<em>工作日 9:00 - 18:00</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,53 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import AppIcon from "@/components/AppIcon.vue";
|
||||
import { ref, onMounted } from '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;
|
||||
}
|
||||
|
||||
const schedules: ScheduleItem[] = [
|
||||
{
|
||||
range: "09.10 ~ 09.30",
|
||||
period: "2026 年 9 月",
|
||||
name: "数据中台 V3.0 全量开放试用",
|
||||
desc: "面向全部客户开放试用,体验实时计算与指标管理能力。",
|
||||
},
|
||||
{
|
||||
range: "10.18 ~ 10.19",
|
||||
period: "2026 年 10 月",
|
||||
name: "云泽数智大会 · 上海站",
|
||||
desc: "30 余位行业客户分享落地实践,现场设产品体验区。",
|
||||
},
|
||||
{
|
||||
range: "11.05 ~ 11.28",
|
||||
period: "2026 年 11 月",
|
||||
name: "全国合作伙伴城市巡展",
|
||||
desc: "覆盖 12 座城市,面向合作伙伴开放产品与政策解读。",
|
||||
},
|
||||
];
|
||||
const upgradeList = ref<UpgradeItem[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 获取平台更新内容列表
|
||||
const fetchUpgradeList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:9000';
|
||||
const response = await fetch(`${API_BASE_URL}/platform/api/upgrade/list?limit=3`);
|
||||
const result = await response.json();
|
||||
if (result.code === 200 && result.data) {
|
||||
upgradeList.value = result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取更新内容失败:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUpgradeList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="schedule">
|
||||
<div class="container schedule__inner">
|
||||
<!-- 活动时间轴 -->
|
||||
<!-- 平台更新时间轴 -->
|
||||
<div class="schedule__panel">
|
||||
<div class="schedule__head">
|
||||
<h2>近期活动与发布节奏</h2>
|
||||
<p>2026 年 · 活动日历</p>
|
||||
<h2>近期平台更新内容</h2>
|
||||
<p>Platform Updates</p>
|
||||
</div>
|
||||
|
||||
<ul class="timeline">
|
||||
<ul v-if="!loading" class="timeline">
|
||||
<li
|
||||
v-for="(item, index) in schedules"
|
||||
:key="item.range"
|
||||
v-for="item in upgradeList"
|
||||
:key="item.id"
|
||||
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 绘制,节点天然居中 -->
|
||||
<span class="timeline__rail" aria-hidden="true">
|
||||
@@ -55,27 +59,33 @@ const schedules: ScheduleItem[] = [
|
||||
<i class="timeline__axis"></i>
|
||||
</span>
|
||||
|
||||
<p class="timeline__period">{{ item.period }}</p>
|
||||
<p class="timeline__name">{{ item.name }}</p>
|
||||
<p class="timeline__desc">{{ item.desc }}</p>
|
||||
<p class="timeline__name">{{ item.title }}</p>
|
||||
<p class="timeline__desc" v-html="item.content"></p>
|
||||
</li>
|
||||
<li v-if="upgradeList.length === 0" class="timeline__empty">
|
||||
暂无更新内容
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="timeline__loading">
|
||||
<el-loading-spinner></el-loading-spinner>
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活动入口 -->
|
||||
<RouterLink to="/events" class="schedule-card">
|
||||
<!-- 管理入口 -->
|
||||
<!-- <RouterLink to="/platform/softwareupgrade" class="schedule-card">
|
||||
<span class="schedule-card__icon">
|
||||
<AppIcon name="calendar" :size="28" :stroke-width="1.5" />
|
||||
<AppIcon name="update" :size="28" :stroke-width="1.5" />
|
||||
</span>
|
||||
<div class="schedule-card__text">
|
||||
<strong>市场活动</strong>
|
||||
<em>参展、发布会与公开课,全年安排一站查看。</em>
|
||||
<strong>更新管理</strong>
|
||||
<em>查看和管理平台更新记录</em>
|
||||
</div>
|
||||
<span class="schedule-card__more">
|
||||
查看全部活动
|
||||
进入管理后台
|
||||
<AppIcon name="arrow-right" :size="16" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</RouterLink> -->
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -108,7 +118,7 @@ const schedules: ScheduleItem[] = [
|
||||
&__inner {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
/* grid-template-columns: minmax(0, 1fr) 360px; */
|
||||
gap: 26px;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -157,7 +167,7 @@ const schedules: ScheduleItem[] = [
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__range {
|
||||
&__date {
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
line-height: 36px;
|
||||
@@ -213,6 +223,15 @@ const schedules: ScheduleItem[] = [
|
||||
color: @ink-400;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 25px;
|
||||
font-weight: 700;
|
||||
line-height: 36px;
|
||||
letter-spacing: 0.01em;
|
||||
color: @ink-900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-top: 8px;
|
||||
font-size: 16px;
|
||||
@@ -237,6 +256,26 @@ const schedules: ScheduleItem[] = [
|
||||
&__item:hover &__name {
|
||||
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;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 21px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
&__axis {
|
||||
margin-right: -34px;
|
||||
}
|
||||
@@ -427,6 +471,11 @@ const schedules: ScheduleItem[] = [
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
&__date {
|
||||
font-size: 19px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin-top: 8px;
|
||||
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