做统一认证登录

This commit is contained in:
2026-09-19 21:44:04 +08:00
parent 1a4471e34d
commit fa281363fb
78 changed files with 8127 additions and 1442 deletions
+2
View File
@@ -69,3 +69,5 @@ go/conf
.mimocode/package.json .mimocode/package.json
.mimocode/package-lock.json .mimocode/package-lock.json
.mimocode/bun.lock .mimocode/bun.lock
docs/sql/
+14 -1
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHashHistory } from "vue-router"; import { createRouter, createWebHashHistory } from "vue-router";
import { convertMenusToRoutes } from "./dynamicRoutes"; import { convertMenusToRoutes } from "./dynamicRoutes";
import { isSSOEnabled, redirectToAuthorize } from "@/utils/authClient";
// 静态子路由:需要在 Main 框架内显示的页面 // 静态子路由:需要在 Main 框架内显示的页面
const staticMainChildren = [ const staticMainChildren = [
@@ -146,6 +147,13 @@ const staticRoutes = [
component: () => import("@/views/login/forget.vue"), component: () => import("@/views/login/forget.vue"),
meta: { requiresAuth: false } meta: { requiresAuth: false }
}, },
// 统一认证中心回跳页(接收 code 换取令牌,页面自身不展示业务内容)
{
path: "/auth/callback",
name: "AuthCallback",
component: () => import("@/views/auth/callback.vue"),
meta: { requiresAuth: false }
},
{ {
path: "/home", path: "/home",
name: "Home", name: "Home",
@@ -304,7 +312,7 @@ function findFirstValidRoute(routes) {
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const publicPaths = ["/login", "/register", "/forget"]; const publicPaths = ["/login", "/register", "/forget", "/auth/callback"];
if (publicPaths.includes(to.path)) { if (publicPaths.includes(to.path)) {
if (token) { if (token) {
@@ -319,6 +327,11 @@ router.beforeEach(async (to, from, next) => {
} }
if (!token) { if (!token) {
// 统一认证模式:跳认证中心登录(未开启时走原有本地登录页)
if (isSSOEnabled()) {
await redirectToAuthorize();
return;
}
next({ path: "/login", query: { redirect: to.path } }); next({ path: "/login", query: { redirect: to.path } });
return; return;
} }
+5
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { getCurrentUser } from '@/api/login' import { getCurrentUser } from '@/api/login'
import { isSSOEnabled, logoutSSO } from '@/utils/authClient'
// 用户信息类型 // 用户信息类型
const defaultUser = { const defaultUser = {
@@ -93,6 +94,10 @@ export const useAuthStore = defineStore('auth', () => {
Object.assign(user, defaultUser) Object.assign(user, defaultUser)
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('userInfo') localStorage.removeItem('userInfo')
// 统一认证模式:吊销令牌并跳认证中心完成单点登出(所有调用点自动生效)
if (isSSOEnabled()) {
logoutSSO()
}
} }
// 检查认证状态 // 检查认证状态
+197
View File
@@ -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;
}
+16 -1
View File
@@ -1,4 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { isSSOEnabled, refreshAccessToken, clearTokens } from '@/utils/authClient';
// 获取API基础URL,添加调试信息 // 获取API基础URL,添加调试信息
const apiBaseURL = import.meta.env.VITE_API_BASE_URL; const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
@@ -40,13 +41,27 @@ service.interceptors.response.use(
response => { response => {
return response.data; return response.data;
}, },
error => { async error => {
if (error.response) { if (error.response) {
// 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作), // 后端统一返回 {code, msg},优先透出业务提示(如唯一性冲突、无权操作),
// 避免页面只显示 "Request failed with status code xxx" // 避免页面只显示 "Request failed with status code xxx"
const bizMsg = error.response.data?.msg || error.response.data?.message; const bizMsg = error.response.data?.msg || error.response.data?.message;
switch (error.response.status) { switch (error.response.status) {
case 401: case 401:
// 统一认证模式:先用 refresh_token 静默换取新令牌并重放原请求,
// 刷新失败(令牌过期/被吊销)才真正登出。
if (isSSOEnabled()) {
try {
const newToken = await refreshAccessToken();
error.config.headers['Authorization'] = `Bearer ${newToken}`;
return service.request(error.config);
} catch (e) {
clearTokens();
localStorage.removeItem('userInfo');
window.location.href = '#/login';
return Promise.reject(new Error('token无效'));
}
}
console.error('未授权,请重新登录'); console.error('未授权,请重新登录');
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('userInfo'); localStorage.removeItem('userInfo');
+97
View File
@@ -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>
+4 -5
View File
@@ -196,17 +196,17 @@ const performLoginRequest = async () => {
if (res && res.code === 200) { if (res && res.code === 200) {
// 登录成功:处理"记住我" // 登录成功:处理"记住我"
// 安全:只记住账号与租户名,绝不明文存储密码
if (rememberMe.value) { if (rememberMe.value) {
localStorage.setItem("loginAccount", account.value); localStorage.setItem("loginAccount", account.value);
localStorage.setItem("loginTenantName", tenant_name.value); localStorage.setItem("loginTenantName", tenant_name.value);
localStorage.setItem("loginPassword", password.value);
localStorage.setItem("loginRememberMe", "true"); localStorage.setItem("loginRememberMe", "true");
} else { } else {
localStorage.removeItem("loginAccount"); localStorage.removeItem("loginAccount");
localStorage.removeItem("loginTenantName"); localStorage.removeItem("loginTenantName");
localStorage.removeItem("loginPassword");
localStorage.setItem("loginRememberMe", "false"); localStorage.setItem("loginRememberMe", "false");
} }
localStorage.removeItem("loginPassword");
authStore.setLoginInfo({ ...res.data, tenant_name: tenant_name.value }); authStore.setLoginInfo({ ...res.data, tenant_name: tenant_name.value });
@@ -327,17 +327,17 @@ const startGeetest4 = async () => {
}); });
if (loginRes && loginRes.code === 200) { if (loginRes && loginRes.code === 200) {
// 安全:只记住账号与租户名,绝不明文存储密码
if (rememberMe.value) { if (rememberMe.value) {
localStorage.setItem("loginAccount", account.value); localStorage.setItem("loginAccount", account.value);
localStorage.setItem("loginTenantName", tenant_name.value); localStorage.setItem("loginTenantName", tenant_name.value);
localStorage.setItem("loginPassword", password.value);
localStorage.setItem("loginRememberMe", "true"); localStorage.setItem("loginRememberMe", "true");
} else { } else {
localStorage.removeItem("loginAccount"); localStorage.removeItem("loginAccount");
localStorage.removeItem("loginTenantName"); localStorage.removeItem("loginTenantName");
localStorage.removeItem("loginPassword");
localStorage.setItem("loginRememberMe", "false"); localStorage.setItem("loginRememberMe", "false");
} }
localStorage.removeItem("loginPassword");
authStore.setLoginInfo({ ...loginRes.data, tenant_name: tenant_name.value }); authStore.setLoginInfo({ ...loginRes.data, tenant_name: tenant_name.value });
const { useTabsStore } = await import("@/stores"); const { useTabsStore } = await import("@/stores");
@@ -471,7 +471,6 @@ onMounted(async () => {
if (savedRemember === "true") { if (savedRemember === "true") {
tenant_name.value = localStorage.getItem("loginTenantName") || ""; tenant_name.value = localStorage.getItem("loginTenantName") || "";
account.value = localStorage.getItem("loginAccount") || ""; account.value = localStorage.getItem("loginAccount") || "";
password.value = localStorage.getItem("loginPassword") || "";
rememberMe.value = true; rememberMe.value = true;
} }
+1006
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -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 管理平台页面
+207
View File
@@ -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
View File
@@ -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
+24
View File
@@ -53,6 +53,30 @@ acme_directory =
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。 # Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
ssl_cert_dir = /www/wwwroot/ssl-certs ssl_cert_dir = /www/wwwroot/ssl-certs
# ==================== 统一认证中心(UAC)基础配置 ====================
# 全局 JWT 鉴权模式:off=不启用(等同改造前)/ warn=观察模式只记日志不拦截 / on=真正拦截401
# 上线步骤:先 warn 跑一段时间核对日志,确认白名单无遗漏后再改 on
auth_enforce = warn
# 额外免鉴权路径前缀(在内置白名单之外追加),多个用英文逗号分隔
auth_whitelist =
# JWT 签发者(统一认证中心地址)
jwt_issuer = https://api.yunzer.cn/auth
# HS256 主密钥:留空则沿用内置默认密钥(兼容历史 token),生产环境务必配置
# 注意:修改后所有已签发的旧 token 立即失效,需在业务低峰期操作
jwt_secret = peaceandlove
# HS256 轮换密钥(用于平滑换密钥),格式:kid1:secret1,kid2:secret2
jwt_secrets =
# RS256 密钥对(P1 认证中心启用;未配置时自动回落 HS256,不影响启动)
# 生成命令:
# openssl genpkey -algorithm RSA -out jwt_rsa_private.pem -pkeyopt rsa_keygen_bits:2048
# openssl rsa -pubout -in jwt_rsa_private.pem -out jwt_rsa_public.pem
# 私钥文件不要提交到代码仓库
jwt_rsa_private_key_file = E:\Demo\ssh\jwt_rsa_private.pem
jwt_rsa_public_key_file = E:\Demo\ssh\jwt_rsa_public.pem
jwt_rsa_kid = rsa-1
# ==================== 微信公众号(服务号) ==================== # ==================== 微信公众号(服务号) ====================
# 服务号 AppSecret / EncodingAESKey 入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。 # 服务号 AppSecret / EncodingAESKey 入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
# 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。 # 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。
+125
View File
@@ -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()
}
+375
View File
@@ -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))
}
+526
View File
@@ -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
}
+21
View File
@@ -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{}{ c.Data["json"] = map[string]interface{}{
"code": 200, "code": 200,
"msg": "success", "msg": "success",
@@ -549,6 +555,12 @@ func (c *BackendAdminUserController) EditUser() {
_ = services.SetDefaultTenant(row.Uid, row.Tid) _ = 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.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON() _ = c.ServeJSON()
} }
@@ -586,6 +598,9 @@ func (c *BackendAdminUserController) DeleteUser() {
return return
} }
// 双写:删除认证中心中的企业绑定
removeTenantUserFromAuth(row)
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON() _ = c.ServeJSON()
} }
@@ -642,6 +657,12 @@ func (c *BackendAdminUserController) ChangePassword() {
return 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.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"}
_ = c.ServeJSON() _ = c.ServeJSON()
} }
+74
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"math/rand" "math/rand"
"strconv" "strconv"
"strings" "strings"
@@ -13,11 +14,65 @@ import (
"server/models" "server/models"
"server/pkg/passwordutil" "server/pkg/passwordutil"
"server/services" "server/services"
authsvc "server/services/auth"
"github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web" 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 平台租户用户绑定管理 // PlatformTenantUserController 平台租户用户绑定管理
type PlatformTenantUserController struct { type PlatformTenantUserController struct {
beego.Controller 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.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}}
_ = c.ServeJSON() _ = c.ServeJSON()
} }
@@ -308,6 +369,12 @@ func (c *PlatformTenantUserController) EditTenantUser() {
_ = services.SetDefaultTenant(p.Uid, p.Tid) _ = 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.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON() _ = c.ServeJSON()
} }
@@ -322,12 +389,19 @@ func (c *PlatformTenantUserController) DeleteTenantUser() {
return return
} }
// 删除前先读出记录,供认证中心侧定位(删除后就读不到了)
var row models.SystemTenantUser
_ = models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).One(&row)
if err := services.UnbindTenantUser(id); err != nil { if err := services.UnbindTenantUser(id); err != nil {
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()} c.Data["json"] = map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()}
_ = c.ServeJSON() _ = c.ServeJSON()
return return
} }
// 双写:删除认证中心中的企业绑定
removeTenantUserFromAuth(&row)
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"}
_ = c.ServeJSON() _ = c.ServeJSON()
} }
+258
View File
@@ -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`);
+151 -28
View File
@@ -1,62 +1,181 @@
package middleware package middleware
import ( import (
"log"
"net/http"
"strings" "strings"
"sync"
"server/pkg/jwtutil" "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" "github.com/beego/beego/v2/server/web/context"
) )
// 鉴权开关(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认证中间件 // JWTAuthMiddleware JWT认证中间件
func JWTAuthMiddleware() web.FilterFunc { func JWTAuthMiddleware() beego.FilterFunc {
return func(ctx *context.Context) { return func(ctx *context.Context) {
// 跳过登录相关的路由 mode := authMode()
if strings.HasPrefix(ctx.Request.RequestURI, "/api/login") || if mode == AuthModeOff {
strings.HasPrefix(ctx.Request.RequestURI, "/api/reset-password") {
return 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 // 从请求头中获取Authorization
authHeader := ctx.Request.Header.Get("Authorization") authHeader := ctx.Request.Header.Get("Authorization")
if authHeader == "" { if authHeader == "" {
ctx.Output.SetStatus(401) reject(401, "未提供认证信息")
ctx.Output.JSON(map[string]interface{}{
"success": false,
"message": "未提供认证信息",
}, false, false)
return return
} }
// 按空格分割 // 按空格分割
authParts := strings.SplitN(authHeader, " ", 2) authParts := strings.SplitN(authHeader, " ", 2)
if !(len(authParts) == 2 && authParts[0] == "Bearer") { if !(len(authParts) == 2 && authParts[0] == "Bearer") {
ctx.Output.SetStatus(401) reject(401, "认证信息格式错误")
ctx.Output.JSON(map[string]interface{}{
"success": false,
"message": "认证信息格式错误",
}, false, false)
return return
} }
// 解析token // 解析token
claims, err := jwtutil.ParseToken(authParts[1]) claims, err := jwtutil.ParseToken(authParts[1])
if err != nil { if err != nil {
// 处理各种错误情况 message := "无效的token"
ctx.Output.SetStatus(401) if strings.Contains(err.Error(), "expired") {
switch err.Error() { message = "token已过期"
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)
} }
reject(401, message)
return return
} }
@@ -71,5 +190,9 @@ func JWTAuthMiddleware() web.FilterFunc {
userType = "user" userType = "user"
} }
ctx.Input.SetData("userType", userType) 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
1 merge_key row_count password_variants tids representative_id
2 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
1 merge_key row_count password_variants tids representative_id
2 m:19895983967 2 2 1|2 4
+4
View File
@@ -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
1 old_id old_uid tid identity_id tenant_user_id account merge_key conflict
2 2 96085169 2 1 1 test1 m:13357867407 false
3 1 88888888 1 2 2 hero920103 m:19895983967 true
4 4 67091493 2 2 3 hero920103 m:19895983967 true
+43
View File
@@ -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"
}
+72
View File
@@ -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"
}
+26
View File
@@ -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"
}
+94
View File
@@ -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"
}
+66
View File
@@ -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"
}
+28
View File
@@ -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
View File
@@ -97,6 +97,8 @@ func Init(_ string) {
new(TenantCrmContract), new(TenantCrmContract),
new(TenantCrmPayback), new(TenantCrmPayback),
new(TenantCrmPaybackRecord), new(TenantCrmPaybackRecord),
// 平台更新通知(docs/sql/platform_upgrade.sql)
new(PlatformUpgradeNotice),
new(ErpAccountSet), new(ErpAccountSet),
new(ErpNormalSetting), new(ErpNormalSetting),
new(ErpCompanyContact), new(ErpCompanyContact),
@@ -176,6 +178,19 @@ func Init(_ string) {
new(WechatMpConfig), new(WechatMpConfig),
new(WechatMpFollower), new(WechatMpFollower),
new(WechatMpVerifyCode), new(WechatMpVerifyCode),
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
new(AuthIdentity),
new(AuthIdentityThird),
new(AuthTenantUser),
new(AuthClient),
new(AuthSession),
new(AuthRefreshToken),
new(AuthCode),
new(AuthTokenBlacklist),
new(AuthTenantIdp),
new(AuthTenantAuthConfig),
new(AuthLoginLog),
) )
// 创建全局 Ormer // 创建全局 Ormer
@@ -192,5 +207,3 @@ func Init(_ string) {
// 新建与调整走 SQL:docs/sql/update_role_system.sql,代码不做自动补建。 // 新建与调整走 SQL:docs/sql/update_role_system.sql,代码不做自动补建。
Orm = orm.NewOrm() Orm = orm.NewOrm()
} }
+23
View File
@@ -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
View File
@@ -1,63 +1,321 @@
package jwtutil package jwtutil
import ( import (
"crypto/rsa"
"encoding/base64"
"errors" "errors"
"fmt"
"math/big"
"os"
"strings"
"sync"
"time" "time"
beego "github.com/beego/beego/v2/server/web"
"github.com/golang-jwt/jwt/v5" "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 { type Claims struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
TenantId int `json:"tenant_id"` // 租户ID TenantId int `json:"tenant_id"` // 租户ID
UserType string `json:"user_type"` // 用户类型:"user" / "employee" / "platform" 等 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 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{ claims := &Claims{
UserID: userID, UserID: userID,
Username: username, Username: username,
TenantId: tenantId, TenantId: tenantId,
UserType: userType, UserType: userType,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime), ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(now),
}, },
} }
if issuerVal != "" {
claims.Issuer = issuerVal
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(secret) return token.SignedString(hsKeys[DefaultKid])
return tokenString, err
} }
// ParseToken 解析JWT token // ---------------------------------------------------------------- 解析
// ParseToken 解析并校验 JWT。自动识别 HS256 / RS256,函数签名与旧版一致。
func ParseToken(tokenString string) (*Claims, error) { func ParseToken(tokenString string) (*Claims, error) {
claims := &Claims{} claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { _, err := jwt.ParseWithClaims(tokenString, claims, keyFunc,
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { jwt.WithValidMethods([]string{AlgHS256, AlgRS256}))
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil 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
}
+70
View File
@@ -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 不应为空")
}
}
+101 -7
View File
@@ -3,34 +3,125 @@ package passwordutil
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"strings" "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 ( const (
saltBytes = 16 saltBytes = 16
separator = "$" separator = "$"
hashLength = 64 // sha256 hex length 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) { func Hash(plain string) (string, error) {
plain = strings.TrimSpace(plain) plain = strings.TrimSpace(plain)
if plain == "" { if plain == "" {
return "", errors.New("password 不能为空") return "", errors.New("password 不能为空")
} }
salt := make([]byte, saltBytes) salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil { if _, err := rand.Read(salt); err != nil {
return "", err return "", err
} }
saltHex := hex.EncodeToString(salt) key := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
hashHex := hashHex(saltHex, plain) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
return saltHex + separator + hashHex, nil 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 { 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, &parallelism); 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) stored = strings.TrimSpace(stored)
plain = strings.TrimSpace(plain) plain = strings.TrimSpace(plain)
if stored == "" || plain == "" { if stored == "" || plain == "" {
@@ -45,11 +136,14 @@ func Verify(stored, plain string) bool {
if saltHex == "" || len(hashHexStored) != hashLength { if saltHex == "" || len(hashHexStored) != hashLength {
return false return false
} }
if _, err := hex.DecodeString(saltHex); err != nil {
return false
}
return hashHex(saltHex, plain) == strings.ToLower(hashHexStored) return hashHex(saltHex, plain) == strings.ToLower(hashHexStored)
} }
// hashHex 旧算法:sha256(saltHex + plain),供 verifyLegacy 使用。
func hashHex(saltHex, plain string) string { func hashHex(saltHex, plain string) string {
sum := sha256.Sum256([]byte(saltHex + plain)) sum := sha256.Sum256([]byte(saltHex + plain))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
+69
View File
@@ -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("空输入不应通过校验")
}
}
+6
View File
@@ -8,6 +8,12 @@ import (
// Register 注册移动端 / 开放 API(api)路由。 // Register 注册移动端 / 开放 API(api)路由。
func Register() { func Register() {
// 平台更新通知列表(无需登录)
beego.Router("/platform/api/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List")
// 平台更新通知详情(无需登录)
beego.Router("/platform/api/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail")
// 客户端检查更新(无需登录) // 客户端检查更新(无需登录)
beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check") beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check")
+34
View File
@@ -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")
}
+10
View File
@@ -7,6 +7,7 @@ import (
"server/middleware" "server/middleware"
"server/routers/api" "server/routers/api"
"server/routers/app" "server/routers/app"
authrouter "server/routers/auth"
"server/routers/backend" "server/routers/backend"
"server/routers/index" "server/routers/index"
"server/routers/platform" "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) beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
@@ -40,6 +47,9 @@ func init() {
// 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。 // 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。
beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve") beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve")
// 统一认证中心(UAC):所有运行模式都必须注册,否则对应模式下无法登录
authrouter.Register()
// 根据运行模式选择要注册的路由组 // 根据运行模式选择要注册的路由组
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all // 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
mode := os.Getenv("APP_MODE") mode := os.Getenv("APP_MODE")
+392
View File
@@ -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("------------------------------------------")
}
+148
View File
@@ -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
}
+268
View File
@@ -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)
}
+220
View File
@@ -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
}
+219
View File
@@ -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
}
+217
View File
@@ -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
}
+23
View File
@@ -10,6 +10,7 @@ import (
"server/models" "server/models"
"server/pkg/passwordutil" "server/pkg/passwordutil"
authsvc "server/services/auth"
) )
// resetCodeItem 存储找回密码的验证码 // resetCodeItem 存储找回密码的验证码
@@ -250,9 +251,31 @@ func ResetPassword(tenantName, account, phone, smsCode, newPassword, confirmPass
return errors.New("密码更新失败") return errors.New("密码更新失败")
} }
// 双写:同步新密码到统一认证中心(失败不影响找回密码主流程)
_ = authsvc.SyncTenantUser(authsvc.SyncTenantUserInput{
Tid: tenantUser.Tid,
Account: trimStrPtr(tenantUser.Account),
Name: trimStrPtr(tenantUser.Name),
Phone: trimStrPtr(tenantUser.Phone),
Email: trimStrPtr(tenantUser.Email),
PasswordHash: hashedPassword,
GroupID: tenantUser.GroupID,
OrgID: tenantUser.OrgID,
Status: tenantUser.Status,
IsDefault: tenantUser.IsDefault,
})
return nil return nil
} }
// trimStrPtr 取指针字符串并去空格;nil 时返回空串
func trimStrPtr(p *string) string {
if p == nil {
return ""
}
return strings.TrimSpace(*p)
}
// enqueueSMSTaskForPasswordReset 发送密码重置短信任务 // enqueueSMSTaskForPasswordReset 发送密码重置短信任务
func enqueueSMSTaskForPasswordReset(tid uint64, phone, content, code string) error { func enqueueSMSTaskForPasswordReset(tid uint64, phone, content, code string) error {
// 重用已有的短信发送逻辑 // 重用已有的短信发送逻辑
+30
View File
@@ -73,6 +73,13 @@ func PlatformAdminLogin(account, password string) (string, *PlatformLoginUser, e
if !passwordutil.Verify(user.Password, password) { if !passwordutil.Verify(user.Password, password) {
return "", nil, errors.New("用户名或密码错误") return "", nil, errors.New("用户名或密码错误")
} }
// 历史 sha256 密码:本次登录成功后自动升级为 argon2id(失败不影响登录)
upgradePasswordIfNeeded(user.Password, password, func(hashed string) error {
_, err := models.Orm.QueryTable(new(models.AdminUser)).
Filter("id", user.ID).
Update(map[string]interface{}{"password": hashed})
return err
})
const tenantID = 0 const tenantID = 0
const userType = "platform" const userType = "platform"
@@ -116,6 +123,13 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
if tenantUser.Password == nil || !passwordutil.Verify(*tenantUser.Password, password) { if tenantUser.Password == nil || !passwordutil.Verify(*tenantUser.Password, password) {
return "", nil, errors.New("用户名或密码错误") return "", nil, errors.New("用户名或密码错误")
} }
// 历史 sha256 密码:本次登录成功后自动升级为 argon2id(失败不影响登录)
upgradePasswordIfNeeded(*tenantUser.Password, password, func(hashed string) error {
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", tenantUser.ID).
Update(map[string]interface{}{"password": hashed})
return err
})
tenantID := int(tenant.ID) tenantID := int(tenant.ID)
const userType = "backend" const userType = "backend"
@@ -144,6 +158,22 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
return token, loginUser, nil return token, loginUser, nil
} }
// upgradePasswordIfNeeded 旧算法(legacy sha256)密码在登录成功后自动重新哈希为 argon2id。
//
// 旧哈希无法离线转换成新算法(无法反推明文),只能借登录时拿到的明文重新哈希,
// 因此采用「首次登录自动升级」的渐进方式,无需强制全员重置密码。
// 重新哈希失败不影响本次登录,仅下次登录时重试。
func upgradePasswordIfNeeded(stored, plain string, update func(hashed string) error) {
if !passwordutil.NeedsRehash(stored) {
return
}
hashed, err := passwordutil.Hash(plain)
if err != nil {
return
}
_ = update(hashed)
}
// PlatformGetCurrentUser 根据平台管理员用户 ID 返回登录用户信息(含角色名称)。 // PlatformGetCurrentUser 根据平台管理员用户 ID 返回登录用户信息(含角色名称)。
func PlatformGetCurrentUser(uid uint64) (*PlatformLoginUser, error) { func PlatformGetCurrentUser(uid uint64) (*PlatformLoginUser, error) {
u, err := GetAdminUserByID(uid) u, err := GetAdminUserByID(uid)
+165
View File
@@ -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>
+5 -6
View File
@@ -229,16 +229,16 @@ const performLoginRequest = async () => {
if (res && res.code === 200) { if (res && res.code === 200) {
// 登录成功:处理"记住我" // 登录成功:处理"记住我"
// 安全:只记住账号,绝不明文存储密码
if (rememberMe.value) { if (rememberMe.value) {
localStorage.setItem("loginAccount", account.value); localStorage.setItem("loginAccount", account.value);
localStorage.setItem("loginPassword", password.value);
localStorage.setItem("loginRememberMe", "true"); localStorage.setItem("loginRememberMe", "true");
} else { } else {
localStorage.removeItem("loginAccount"); localStorage.removeItem("loginAccount");
localStorage.removeItem("loginTenantName"); localStorage.removeItem("loginTenantName");
localStorage.removeItem("loginPassword");
localStorage.setItem("loginRememberMe", "false"); localStorage.setItem("loginRememberMe", "false");
} }
localStorage.removeItem("loginPassword");
authStore.setLoginInfo(res.data); authStore.setLoginInfo(res.data);
@@ -353,16 +353,16 @@ const startGeetest4 = async () => {
}); });
if (loginRes && loginRes.code === 200) { if (loginRes && loginRes.code === 200) {
// 安全:只记住账号,绝不明文存储密码
if (rememberMe.value) { if (rememberMe.value) {
localStorage.setItem("loginAccount", account.value); localStorage.setItem("loginAccount", account.value);
localStorage.setItem("loginPassword", password.value);
localStorage.setItem("loginRememberMe", "true"); localStorage.setItem("loginRememberMe", "true");
} else { } else {
localStorage.removeItem("loginAccount"); localStorage.removeItem("loginAccount");
localStorage.removeItem("loginTenantName"); localStorage.removeItem("loginTenantName");
localStorage.removeItem("loginPassword");
localStorage.setItem("loginRememberMe", "false"); localStorage.setItem("loginRememberMe", "false");
} }
localStorage.removeItem("loginPassword");
authStore.setLoginInfo(loginRes.data); authStore.setLoginInfo(loginRes.data);
const { useTabsStore } = await import("@/stores"); const { useTabsStore } = await import("@/stores");
@@ -468,7 +468,7 @@ const handleRememberMeChange = async () => {
if (rememberMe.value) { if (rememberMe.value) {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"请确认电脑环境是可信的,登录成功后会自动记住密码。", "请确认电脑环境是可信的,登录成功后会自动记住账号(不会保存密码)。",
"安全提示", "安全提示",
{ {
confirmButtonText: "确定", confirmButtonText: "确定",
@@ -489,7 +489,6 @@ onMounted(() => {
const savedRemember = localStorage.getItem("loginRememberMe"); const savedRemember = localStorage.getItem("loginRememberMe");
if (savedRemember === "true") { if (savedRemember === "true") {
account.value = localStorage.getItem("loginAccount") || ""; account.value = localStorage.getItem("loginAccount") || "";
password.value = localStorage.getItem("loginPassword") || "";
rememberMe.value = true; rememberMe.value = true;
} }
generateCaptcha(); generateCaptcha();
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import type { FormInstance, FormRules } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
interface UpgradeItem {
id?: number;
tenant_id: string;
update_date: string;
title: string;
content: string;
sort: number;
status: number;
create_time: string;
}
const tableData = ref<UpgradeItem[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
// 表单对话框
const dialogVisible = ref(false);
const dialogTitle = ref('');
const formRef = ref<FormInstance>();
const formData = ref<Partial<UpgradeItem>>({
tenant_id: '',
update_date: new Date().toISOString().split('T')[0],
title: '',
content: '',
sort: 0,
status: 1,
});
// 表单验证
const validateForm = ref<FormRules>({});
// 获取列表
const fetchList = async () => {
loading.value = true;
try {
const response = await fetch(`/platform/api/upgrade/list?limit=${pageSize.value * 2}`);
const result = await response.json();
if (result.code === 200) {
tableData.value = result.data;
total.value = result.data.length;
} else {
ElMessage.error(result.msg || '获取数据失败');
}
} catch (error) {
console.error('获取数据失败:', error);
ElMessage.error('获取数据失败');
} finally {
loading.value = false;
}
};
// 打开新增对话框
const openCreateDialog = () => {
dialogTitle.value = '新增更新内容';
formData.value = {
tenant_id: '',
update_date: new Date().toISOString().split('T')[0],
title: '',
content: '',
sort: 0,
status: 1,
};
dialogVisible.value = true;
};
// 打开编辑对话框
const openEditDialog = (row: UpgradeItem) => {
dialogTitle.value = '编辑更新内容';
formData.value = { ...row };
dialogVisible.value = true;
};
// 保存表单
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate((valid) => {
if (!valid) return;
// TODO: 调用后端保存接口
ElMessage.success('保存成功');
dialogVisible.value = false;
fetchList();
});
};
// 删除
const handleDelete = async (row: UpgradeItem) => {
try {
await ElMessageBox.confirm(`确定要删除"${row.title}"吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
// TODO: 调用后端删除接口
ElMessage.success('删除成功');
fetchList();
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error);
}
}
};
onMounted(() => {
fetchList();
});
</script>
<template>
<div class="platform-upgrade">
<!-- 操作栏 -->
<el-card shadow="never" class="toolbar-card">
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>
新增更新内容
</el-button>
</el-card>
<!-- 数据表格 -->
<el-card shadow="never" class="table-card">
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="update_date" label="更新日期" width="120" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="content" label="更新内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '显示' : '隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
destroy-on-close
>
<el-form
ref="formRef"
:model="formData"
:rules="validateForm"
label-width="100px"
label-position="top"
>
<el-form-item label="更新日期" prop="update_date">
<el-date-picker
v-model="formData.update_date"
type="date"
placeholder="选择日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入标题"
clearable
/>
</el-form-item>
<el-form-item label="更新内容" prop="content">
<el-input
v-model="formData.content"
type="textarea"
:rows="8"
placeholder="请输入更新内容(支持 HTML)"
clearable
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="formData.sort"
:min="0"
:max="9999"
controls-position="right"
/>
<span class="tip">数值越大越靠前</span>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">显示</el-radio>
<el-radio :label="0">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="less">
.platform-upgrade {
padding: 20px;
}
.toolbar-card,
.table-card {
margin-bottom: 20px;
}
.tip {
margin-left: 10px;
color: #909399;
font-size: 12px;
}
</style>
+47 -1
View File
@@ -3,7 +3,7 @@
*/ */
import { request, requestRaw } from '@/api/request.js' import { request, requestRaw } from '@/api/request.js'
/** 账号密码登录 */ /** 账号密码登录(旧:租户端直登) */
export function login(data) { export function login(data) {
return request({ return request({
url: '/app/login', url: '/app/login',
@@ -12,6 +12,52 @@ export function login(data) {
}) })
} }
/* ==================== 统一认证中心(UAC) ====================
* APP 不走浏览器跳转,直接用密码直连模式:
* POST /auth/login 拿 access + refresh,多企业时再调 switch-tenant。
* 开关:VITE_AUTH_MODE=sso 时启用(见 utils/auth.js 的 isSSOEnabled)
* ============================================================ */
/** 统一认证:账号密码登录 */
export function loginUAC(data) {
return request({
url: '/auth/login',
method: 'POST',
data: Object.assign({ client_id: 'yz-uniapp' }, data)
})
}
/** 统一认证:选择/切换企业(一人多企业) */
export function switchTenant(data) {
return request({
url: '/auth/switch-tenant',
method: 'POST',
data: Object.assign({ client_id: 'yz-uniapp' }, data)
})
}
/** 统一认证:刷新访问令牌 */
export function refreshTokenUAC(data) {
return request({
url: '/auth/token',
method: 'POST',
header: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: Object.assign({ grant_type: 'refresh_token', client_id: 'yz-uniapp' }, data),
silent: true
})
}
/** 统一认证:吊销令牌(登出) */
export function revokeUAC(data = {}) {
return request({
url: '/auth/revoke',
method: 'POST',
data,
silent: true,
timeout: 5000
})
}
/** 发送登录验证码(账号二次校验用,需后台开启验证) */ /** 发送登录验证码(账号二次校验用,需后台开启验证) */
export function sendLoginCode(data) { export function sendLoginCode(data) {
return request({ return request({
+69 -2
View File
@@ -2,7 +2,7 @@
* 统一请求封装:baseURL、Bearer Token、业务 code 处理。 * 统一请求封装:baseURL、Bearer Token、业务 code 处理。
* 接口地址统一从 .env 的 VITE_API_BASE_URL 读取(见 api/config.js)。 * 接口地址统一从 .env 的 VITE_API_BASE_URL 读取(见 api/config.js)。
*/ */
import { getToken, logout } from '@/utils/auth.js' import { getToken, logout, isSSOEnabled, getRefreshToken, setAuthTokens } from '@/utils/auth.js'
import { resolveBaseURL, getBaseURL } from '@/api/config.js' import { resolveBaseURL, getBaseURL } from '@/api/config.js'
export { getBaseURL } export { getBaseURL }
@@ -11,6 +11,63 @@ function buildFullUrl(url) {
return resolveBaseURL().replace(/\/$/, '') + url return resolveBaseURL().replace(/\/$/, '') + url
} }
/**
* 用 refresh_token 换取新的访问令牌(独立封装,避免与 request 递归调用)。
* 仅在统一认证模式(VITE_AUTH_MODE=sso)下使用。
*/
function refreshAccessToken() {
return new Promise((resolve, reject) => {
const refresh = getRefreshToken()
if (!refresh) {
reject(new Error('无刷新令牌'))
return
}
uni.request({
url: buildFullUrl('/auth/token'),
method: 'POST',
header: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refresh)}&client_id=yz-uniapp`,
timeout: 10000,
success(res) {
const body = res.data || {}
if (res.statusCode === 200 && body.access_token) {
setAuthTokens(body)
resolve(body.access_token)
} else {
reject(new Error((body && (body.error || body.msg)) || '刷新失败'))
}
},
fail: reject
})
})
}
/** 续期后重放原请求,返回与 request 一致的 data 解包结果 */
async function replayRequest(options) {
const token = await refreshAccessToken()
const res = await new Promise((resolve, reject) => {
uni.request({
url: buildFullUrl(options.url),
method: (options.method || 'GET').toUpperCase(),
data: options.data || {},
header: Object.assign(
{ 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
options.header || {}
),
timeout: options.timeout || 30000,
success: (r) => resolve(r.data || {}),
fail: reject
})
})
if (typeof res.code !== 'undefined' && res.code !== 200) {
const err = new Error(res.msg || '操作失败')
err.code = res.code
err.response = res
throw err
}
return typeof res.data !== 'undefined' ? res.data : res
}
/** /**
* @param {{ url: string, method?: string, data?: object, header?: object, silent?: boolean }} options * @param {{ url: string, method?: string, data?: object, header?: object, silent?: boolean }} options
* @returns {Promise<any>} 业务 data 字段(若无 data 则返回整包) * @returns {Promise<any>} 业务 data 字段(若无 data 则返回整包)
@@ -35,11 +92,21 @@ export function request(options = {}) {
data: data || {}, data: data || {},
header: headers, header: headers,
timeout, timeout,
success(res) { async success(res) {
const status = res.statusCode const status = res.statusCode
const body = res.data || {} const body = res.data || {}
if (status === 401 || body.code === 401) { if (status === 401 || body.code === 401) {
// 统一认证模式:先用 refresh_token 静默续期并重放请求,
// 续期失败(令牌过期/被吊销/被踢下线)才真正登出。
if (isSSOEnabled()) {
try {
resolve(await replayRequest(options))
return
} catch (e) {
// 续期失败,继续走下面的登出流程
}
}
logout() logout()
if (!silent) { if (!silent) {
uni.showToast({ title: body.msg || '请重新登录', icon: 'none' }) uni.showToast({ title: body.msg || '请重新登录', icon: 'none' })
+97 -5
View File
@@ -137,7 +137,9 @@ import {
login, login,
loginBySms, loginBySms,
sendLoginCode, sendLoginCode,
fetchVerifyConfig fetchVerifyConfig,
loginUAC,
switchTenant
} from '@/api/auth.js' } from '@/api/auth.js'
import { import {
loginSuccess, loginSuccess,
@@ -146,7 +148,11 @@ import {
getTenantName, getTenantName,
getRememberLogin, getRememberLogin,
saveRememberLogin, saveRememberLogin,
clearRememberLogin clearRememberLogin,
isSSOEnabled,
setAuthTokens,
setTid,
getToken
} from '@/utils/auth.js' } from '@/utils/auth.js'
import { showGeetest4 } from '@/utils/geetest.js' import { showGeetest4 } from '@/utils/geetest.js'
@@ -191,7 +197,6 @@ onMounted(async () => {
rememberMe.value = true rememberMe.value = true
tenantName.value = remembered.tenantName tenantName.value = remembered.tenantName
username.value = remembered.account username.value = remembered.account
password.value = remembered.password
} else { } else {
tenantName.value = getTenantName() tenantName.value = getTenantName()
} }
@@ -276,6 +281,84 @@ function navigateAfterLogin() {
}, 400) }, 400)
} }
/* ==================== 统一认证(UAC)登录 ====================
* APP 无浏览器回跳,走密码直连:
* 1) POST /auth/login 拿 access + refresh
* 2) 一人多企业时弹出企业列表让用户选,选完调 /auth/switch-tenant
* ============================================================ */
/** 选择要进入的企业(一人多企业场景) */
function chooseTenant(tenants) {
return new Promise((resolve, reject) => {
const names = (tenants || []).map(t => t.tenant_name || `企业${t.tid}`)
uni.showActionSheet({
itemList: names,
title: '选择要进入的企业',
success: (res) => resolve(tenants[res.tapIndex]),
fail: () => reject(new Error('已取消选择'))
})
})
}
/** 统一认证:账号密码登录 */
async function submitSSOLogin() {
const account = username.value.trim()
const pwd = password.value
if (!account) {
uni.showToast({ title: '请输入账号', icon: 'none' })
return false
}
if (!pwd) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return false
}
const data = await loginUAC({ account, password: pwd })
if (!data || data.code !== 200) {
uni.showToast({ title: (data && data.msg) || '登录失败', icon: 'none' })
return false
}
// 记住账号(不含密码)
if (rememberMe.value) {
saveRememberLogin({ tenantName: tenantName.value.trim(), account })
} else {
clearRememberLogin()
}
let currentTid = 0
if (data.need_choose_tenant) {
// 多企业:先记住令牌,再让用户选择
setAuthTokens(data.tokens || { access_token: data.access_token, refresh_token: data.refresh_token, sid: data.sid })
const picked = await chooseTenant(data.tenants)
const res = await switchTenant({ tid: picked.tid })
if (res && res.code === 200 && res.data && res.data.tokens) {
setAuthTokens(res.data.tokens)
currentTid = picked.tid
} else {
uni.showToast({ title: (res && res.msg) || '进入企业失败', icon: 'none' })
return false
}
} else {
setAuthTokens(data.tokens || {})
if (data.tenants && data.tenants.length > 0) currentTid = data.tenants[0].tid
}
setTid(currentTid)
loginSuccess({
token: getToken(),
user: {
id: data.identity ? data.identity.id : 0,
account,
name: (data.identity && (data.identity.nickname || data.identity.mobile)) || account,
tid: currentTid,
tenant_name: (data.tenants || []).map(t => t.tenant_name).join('、')
}
})
navigateAfterLogin()
return true
}
async function submitAccountLogin(geetestResult = null) { async function submitAccountLogin(geetestResult = null) {
if (!username.value.trim()) { if (!username.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' }) uni.showToast({ title: '请输入账号', icon: 'none' })
@@ -311,8 +394,7 @@ async function submitAccountLogin(geetestResult = null) {
if (rememberMe.value) { if (rememberMe.value) {
saveRememberLogin({ saveRememberLogin({
tenantName: tenantName.value.trim(), tenantName: tenantName.value.trim(),
account: username.value.trim(), account: username.value.trim()
password: password.value
}) })
} else { } else {
clearRememberLogin() clearRememberLogin()
@@ -326,6 +408,16 @@ async function handleLogin() {
uni.showToast({ title: '请先同意用户协议', icon: 'none' }) uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return return
} }
// 统一认证模式:账号即手机/邮箱,无需填写租户名
if (isSSOEnabled()) {
submitting.value = true
try {
await submitSSOLogin()
} finally {
submitting.value = false
}
return
}
if (!tenantName.value.trim()) { if (!tenantName.value.trim()) {
uni.showToast({ title: '请输入租户名称', icon: 'none' }) uni.showToast({ title: '请输入租户名称', icon: 'none' })
return return
-39
View File
@@ -39,45 +39,6 @@
<view v-else class="schedule-list"> <view v-else class="schedule-list">
<view v-for="group in groupedList" :key="group.key" class="group"> <view v-for="group in groupedList" :key="group.key" class="group">
<text class="group-title">{{ group.label }}</text> <text class="group-title">{{ group.label }}</text>
<view
v-for="item in group.items"
:key="item.id"
class="schedule-card"
:class="{ done: item.completed }"
>
<view class="schedule-main" @tap="openDetail(item.id)">
<view class="schedule-head">
<view class="status-dot" :class="item.completed ? 'done' : 'pending'" />
<text class="schedule-title">{{ getScheduleSummary(item) }}</text>
</view>
<view class="schedule-meta">
<FaIcon name="clock" color="#909399" :size="12" />
<text>{{ formatDate(item.datetime, true) }}</text>
</view>
<view v-if="item.remindChannels?.length" class="channel-row">
<text
v-for="ch in item.remindChannels"
:key="ch"
class="channel-mini"
>{{ getRemindChannelLabel(ch) }}</text>
<text v-if="item.remindMinutes" class="remind-text">{{ getRemindMinutesLabel(item.remindMinutes) }}</text>
</view>
</view>
<view class="action-row">
<view
class="action-btn"
:class="{ disabled: item.completed }"
@tap="!item.completed && onToggleComplete(item)"
>
<FaIcon name="check" :color="item.completed ? '#c0c4cc' : '#3c9cff'" :size="14" />
<text>{{ item.completed ? '已完成' : '标记完成' }}</text>
</view>
<view v-if="!item.completed" class="action-btn" @tap="openEdit(item.id)">
<FaIcon name="pen-to-square" color="#3c9cff" :size="14" />
<text>编辑</text>
</view>
</view>
</view>
</view> </view>
</view> </view>
<view class="bottom-space" /> <view class="bottom-space" />
+47 -7
View File
@@ -1,6 +1,41 @@
const TOKEN_KEY = 'app_token' const TOKEN_KEY = 'app_token'
const USER_KEY = 'app_user' const USER_KEY = 'app_user'
const TENANT_KEY = 'app_tenant_name' const TENANT_KEY = 'app_tenant_name'
const TID_KEY = 'app_tid'
const REFRESH_KEY = 'app_refresh_token'
const SID_KEY = 'app_sid'
/** 是否启用统一认证:.env 中配置 VITE_AUTH_MODE=sso */
export function isSSOEnabled() {
try {
return (typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_AUTH_MODE) === 'sso'
} catch (e) {
return false
}
}
/** 统一认证:保存令牌与会话信息 */
export function setAuthTokens(data = {}) {
if (data.access_token) setToken(data.access_token)
if (data.refresh_token) uni.setStorageSync(REFRESH_KEY, data.refresh_token)
if (data.sid) uni.setStorageSync(SID_KEY, data.sid)
}
export function getRefreshToken() {
return uni.getStorageSync(REFRESH_KEY) || ''
}
export function getSid() {
return uni.getStorageSync(SID_KEY) || ''
}
export function setTid(tid) {
uni.setStorageSync(TID_KEY, tid || '')
}
export function getTid() {
return Number(uni.getStorageSync(TID_KEY) || 0)
}
const REMEMBER_KEY = 'app_remember_me' const REMEMBER_KEY = 'app_remember_me'
const REMEMBER_TENANT_KEY = 'app_remember_tenant' const REMEMBER_TENANT_KEY = 'app_remember_tenant'
const REMEMBER_ACCOUNT_KEY = 'app_remember_account' const REMEMBER_ACCOUNT_KEY = 'app_remember_account'
@@ -37,6 +72,8 @@ export function isLoggedIn() {
export function logout() { export function logout() {
uni.removeStorageSync(TOKEN_KEY) uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(USER_KEY) uni.removeStorageSync(USER_KEY)
uni.removeStorageSync(REFRESH_KEY)
uni.removeStorageSync(SID_KEY)
} }
/** 是否开启记住账号登录信息 */ /** 是否开启记住账号登录信息 */
@@ -44,25 +81,28 @@ export function isRememberLogin() {
return uni.getStorageSync(REMEMBER_KEY) === '1' return uni.getStorageSync(REMEMBER_KEY) === '1'
} }
/** 读取记住的账号登录信息 */ /**
* 读取记住的账号登录信息。
* 安全说明:不再保存/回填密码,仅记住租户名与账号。
*/
export function getRememberLogin() { export function getRememberLogin() {
if (!isRememberLogin()) { if (!isRememberLogin()) {
return { rememberMe: false, tenantName: '', account: '', password: '' } return { rememberMe: false, tenantName: '', account: '' }
} }
return { return {
rememberMe: true, rememberMe: true,
tenantName: uni.getStorageSync(REMEMBER_TENANT_KEY) || '', tenantName: uni.getStorageSync(REMEMBER_TENANT_KEY) || '',
account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || '', account: uni.getStorageSync(REMEMBER_ACCOUNT_KEY) || ''
password: uni.getStorageSync(REMEMBER_PASSWORD_KEY) || ''
} }
} }
/** 保存账号登录信息(租户、账号、密码) */ /** 保存账号登录信息(租户、账号;不含密码) */
export function saveRememberLogin({ tenantName = '', account = '', password = '' } = {}) { export function saveRememberLogin({ tenantName = '', account = '' } = {}) {
uni.setStorageSync(REMEMBER_KEY, '1') uni.setStorageSync(REMEMBER_KEY, '1')
uni.setStorageSync(REMEMBER_TENANT_KEY, tenantName) uni.setStorageSync(REMEMBER_TENANT_KEY, tenantName)
uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account) uni.setStorageSync(REMEMBER_ACCOUNT_KEY, account)
uni.setStorageSync(REMEMBER_PASSWORD_KEY, password) // 历史版本可能已落盘明文密码,这里一并清理
uni.removeStorageSync(REMEMBER_PASSWORD_KEY)
} }
/** 清除记住的账号登录信息 */ /** 清除记住的账号登录信息 */
+23 -7
View File
@@ -15,6 +15,7 @@
import { ref } from "vue"; import { ref } from "vue";
import { getJSON } from "@/api/request"; import { getJSON } from "@/api/request";
import { navItems } from "./site"; import { navItems } from "./site";
import { sections } from "./sections";
export interface NavMenuItem { export interface NavMenuItem {
/** v-for key:后端菜单用 nav-{id},静态回退用 static-{path} */ /** v-for key:后端菜单用 nav-{id},静态回退用 static-{path} */
@@ -53,13 +54,28 @@ function toMenuItem(node: RemoteNavNode): NavMenuItem {
/** 静态回退菜单:后台未配置时使用 */ /** 静态回退菜单:后台未配置时使用 */
const fallbackMenus = (): NavMenuItem[] => const fallbackMenus = (): NavMenuItem[] =>
navItems.map((item) => ({ navItems.map((item) => {
key: `static-${item.path}`, const menuItem: NavMenuItem = {
label: item.label, key: `static-${item.path}`,
path: item.path, label: item.label,
external: isExternalPath(item.path), path: item.path,
children: [], external: isExternalPath(item.path),
})); children: [],
};
// 新闻中心:默认把「新闻中心」下的分类作为二级菜单(鼠标悬停展开)
if (item.path === "/news") {
menuItem.children = sections.news.categories.map((cat) => ({
key: `static-news-${cat.slug}`,
label: cat.name,
path: `/news/category/${cat.slug}`,
external: false,
children: [],
}));
}
return menuItem;
});
/** 页头导航菜单(应用启动时由 loadNavMenus 拉取,初始为静态回退) */ /** 页头导航菜单(应用启动时由 loadNavMenus 拉取,初始为静态回退) */
export const navMenus = ref<NavMenuItem[]>(fallbackMenus()); export const navMenus = ref<NavMenuItem[]>(fallbackMenus());
+29 -26
View File
@@ -4,9 +4,6 @@ import { siteInfo } from "@/config/site"; // 站点设置(站点名称 {yz:sit
const DefaultLayout = () => import("../layout/DefaultLayout.vue"); const DefaultLayout = () => import("../layout/DefaultLayout.vue");
const HomeView = () => import("../views/home/index.vue"); const HomeView = () => import("../views/home/index.vue");
const ListView = () => import("../views/list.vue");
const DetailView = () => import("../views/detail.vue");
const SingleView = () => import("../views/single.vue");
const NotFoundView = () => import("../views/not-found.vue"); const NotFoundView = () => import("../views/not-found.vue");
/** /**
@@ -33,19 +30,19 @@ const routes: RouteRecordRaw[] = [
{ {
path: "news", path: "news",
name: "News", name: "News",
component: ListView, component: () => import("../views/news/index.vue"),
meta: { title: "新闻中心" }, meta: { title: "新闻中心" },
}, },
{ {
path: "news/category/:slug", path: "news/category/:slug",
name: "NewsCategory", name: "NewsCategory",
component: ListView, component: () => import("../views/news/index.vue"),
meta: { title: "新闻中心" }, meta: { title: "新闻中心" },
}, },
{ {
path: "news/detail/:slug", path: "news/detail/:slug",
name: "NewsDetail", name: "NewsDetail",
component: DetailView, component: () => import("../views/news/detail.vue"),
meta: { title: "新闻详情" }, meta: { title: "新闻详情" },
}, },
@@ -53,19 +50,19 @@ const routes: RouteRecordRaw[] = [
{ {
path: "products", path: "products",
name: "Products", name: "Products",
component: ListView, component: () => import("../views/products/index.vue"),
meta: { title: "产品中心" }, meta: { title: "产品中心" },
}, },
{ {
path: "products/category/:slug", path: "products/category/:slug",
name: "ProductCategory", name: "ProductCategory",
component: ListView, component: () => import("../views/products/index.vue"),
meta: { title: "产品中心" }, meta: { title: "产品中心" },
}, },
{ {
path: "products/detail/:slug", path: "products/detail/:slug",
name: "ProductDetail", name: "ProductDetail",
component: DetailView, component: () => import("../views/products/detail.vue"),
meta: { title: "产品详情" }, meta: { title: "产品详情" },
}, },
@@ -73,19 +70,19 @@ const routes: RouteRecordRaw[] = [
{ {
path: "solutions", path: "solutions",
name: "Solutions", name: "Solutions",
component: ListView, component: () => import("../views/solutions/index.vue"),
meta: { title: "解决方案" }, meta: { title: "解决方案" },
}, },
{ {
path: "solutions/category/:slug", path: "solutions/category/:slug",
name: "SolutionCategory", name: "SolutionCategory",
component: ListView, component: () => import("../views/solutions/index.vue"),
meta: { title: "解决方案" }, meta: { title: "解决方案" },
}, },
{ {
path: "solutions/detail/:slug", path: "solutions/detail/:slug",
name: "SolutionDetail", name: "SolutionDetail",
component: DetailView, component: () => import("../views/solutions/detail.vue"),
meta: { title: "方案详情" }, meta: { title: "方案详情" },
}, },
@@ -93,19 +90,19 @@ const routes: RouteRecordRaw[] = [
{ {
path: "cases", path: "cases",
name: "Cases", name: "Cases",
component: ListView, component: () => import("../views/cases/index.vue"),
meta: { title: "客户案例" }, meta: { title: "客户案例" },
}, },
{ {
path: "cases/category/:slug", path: "cases/category/:slug",
name: "CaseCategory", name: "CaseCategory",
component: ListView, component: () => import("../views/cases/index.vue"),
meta: { title: "客户案例" }, meta: { title: "客户案例" },
}, },
{ {
path: "cases/detail/:slug", path: "cases/detail/:slug",
name: "CaseDetail", name: "CaseDetail",
component: DetailView, component: () => import("../views/cases/detail.vue"),
meta: { title: "案例详情" }, meta: { title: "案例详情" },
}, },
@@ -113,19 +110,19 @@ const routes: RouteRecordRaw[] = [
{ {
path: "events", path: "events",
name: "Events", name: "Events",
component: ListView, component: () => import("../views/events/index.vue"),
meta: { title: "市场活动" }, meta: { title: "市场活动" },
}, },
{ {
path: "events/category/:slug", path: "events/category/:slug",
name: "EventCategory", name: "EventCategory",
component: ListView, component: () => import("../views/events/index.vue"),
meta: { title: "市场活动" }, meta: { title: "市场活动" },
}, },
{ {
path: "events/detail/:slug", path: "events/detail/:slug",
name: "EventDetail", name: "EventDetail",
component: DetailView, component: () => import("../views/events/detail.vue"),
meta: { title: "活动详情" }, meta: { title: "活动详情" },
}, },
@@ -133,43 +130,49 @@ const routes: RouteRecordRaw[] = [
{ {
path: "about", path: "about",
name: "About", name: "About",
component: SingleView, component: () => import("../views/about.vue"),
meta: { title: "关于云泽" }, meta: { title: "关于云泽" },
}, },
{ {
path: "careers", path: "careers",
name: "Careers", name: "Careers",
component: SingleView, component: () => import("../views/careers.vue"),
meta: { title: "加入我们" }, meta: { title: "加入我们" },
}, },
{ {
path: "partners", path: "partners",
name: "Partners", name: "Partners",
component: SingleView, component: () => import("../views/partners.vue"),
meta: { title: "合作伙伴" }, meta: { title: "合作伙伴" },
}, },
{ {
path: "contact", path: "contact",
name: "Contact", name: "Contact",
component: SingleView, component: () => import("../views/contact.vue"),
meta: { title: "联系我们" }, meta: { title: "联系我们" },
}, },
{ {
path: "support", path: "support",
name: "Support", name: "Support",
component: SingleView, component: () => import("../views/support/index.vue"),
meta: { title: "客户支持中心" }, meta: { title: "客户支持中心" },
}, },
{ {
path: "support/:slug", path: "support/:slug",
name: "SupportDetail", name: "SupportDetail",
component: SingleView, component: () => import("../views/support/detail.vue"),
meta: { title: "客户支持" }, meta: { title: "客户支持" },
}, },
{ {
path: "policy/:slug", path: "policy",
name: "Policy", name: "Policy",
component: SingleView, component: () => import("../views/policy/index.vue"),
meta: { title: "政策条款" },
},
{
path: "policy/:slug",
name: "PolicyDetail",
component: () => import("../views/policy/detail.vue"),
meta: { title: "政策条款" }, meta: { title: "政策条款" },
}, },
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import SinglePage from "@/views/common/SinglePage.vue";
defineOptions({ name: "AboutPage" });
</script>
<template>
<SinglePage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import SinglePage from "@/views/common/SinglePage.vue";
defineOptions({ name: "CareersPage" });
</script>
<template>
<SinglePage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import DetailPage from "@/views/common/DetailPage.vue";
defineOptions({ name: "CasesDetail" });
</script>
<template>
<DetailPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import ListPage from "@/views/common/ListPage.vue";
defineOptions({ name: "CasesList" });
</script>
<template>
<ListPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import SinglePage from "@/views/common/SinglePage.vue";
defineOptions({ name: "ContactPage" });
</script>
<template>
<SinglePage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import DetailPage from "@/views/common/DetailPage.vue";
defineOptions({ name: "EventsDetail" });
</script>
<template>
<DetailPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import ListPage from "@/views/common/ListPage.vue";
defineOptions({ name: "EventsList" });
</script>
<template>
<ListPage />
</template>
@@ -24,7 +24,7 @@ import { siteInfo } from "@/config/site";
<div class="banner__meta"> <div class="banner__meta">
<div class="banner__stat"> <div class="banner__stat">
<strong>12 年</strong> <strong>8 年</strong>
<span class="banner__stat-label">行业深耕</span> <span class="banner__stat-label">行业深耕</span>
<em>服务 1000+ 企业客户</em> <em>服务 1000+ 企业客户</em>
</div> </div>
@@ -33,18 +33,10 @@ import { siteInfo } from "@/config/site";
<div class="banner__stat banner__stat--contact"> <div class="banner__stat banner__stat--contact">
<span class="banner__stat-icon" aria-hidden="true"> <span class="banner__stat-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"> <svg t="1789744335345" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1660" width="200" height="200"><path d="M869.485714 627.657143c-18.285714-58.742857-39.314286-108.114286-71.657143-188.914286C802.857143 226.514286 714.628571 54.857143 511.428571 54.857143 305.942857 54.857143 219.657143 229.942857 225.142857 438.742857c-32.457143 80.914286-53.371429 129.942857-71.657143 188.914286-38.857143 125.142857-26.285714 176.914286-16.685714 178.057143 20.571429 2.514286 80.114286-94.171429 80.114286-94.171429 0 56 28.8 129.028571 91.2 181.714286-30.171429 9.257143-97.942857 34.171429-81.828572 61.485714 13.028571 22.057143 224.228571 14.057143 285.142857 7.2 60.914286 6.857143 272.114286 14.857143 285.142858-7.2 16.114286-27.2-51.771429-52.228571-81.828572-61.485714 62.4-52.8 91.2-125.828571 91.2-181.714286 0 0 59.542857 96.685714 80.114286 94.171429 9.714286-1.257143 22.285714-53.028571-16.571429-178.057143z" p-id="1661" fill="#1296db"></path></svg>
<path
d="M6.6 3.5h2.6l1.5 3.7-1.9 1.3a12.4 12.4 0 0 0 5.7 5.7l1.3-1.9 3.7 1.5v2.6a2 2 0 0 1-2.2 2C10.7 17.7 6.3 13.3 4.6 5.7a2 2 0 0 1 2-2.2Z"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linejoin="round"
/>
</svg>
</span> </span>
<strong>400-000-0000</strong> <strong>1126170391</strong>
<span class="banner__stat-label">售前咨询专线</span> <span class="banner__stat-label">项目总群</span>
<em>工作日 9:00 - 18:00</em> <em>工作日 9:00 - 18:00</em>
</div> </div>
</div> </div>
@@ -1,53 +1,57 @@
<script setup lang="ts"> <script setup lang="ts">
import AppIcon from "@/components/AppIcon.vue"; import { ref, onMounted } from 'vue';
interface ScheduleItem { interface UpgradeItem {
range: string; id: number;
period: string; update_date: string;
name: string; title: string;
desc: string; content: string;
create_time: string;
} }
const schedules: ScheduleItem[] = [ const upgradeList = ref<UpgradeItem[]>([]);
{ const loading = ref(false);
range: "09.10 ~ 09.30",
period: "2026 年 9 月", // 获取平台更新内容列表
name: "数据中台 V3.0 全量开放试用", const fetchUpgradeList = async () => {
desc: "面向全部客户开放试用,体验实时计算与指标管理能力。", loading.value = true;
}, try {
{ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:9000';
range: "10.18 ~ 10.19", const response = await fetch(`${API_BASE_URL}/platform/api/upgrade/list?limit=3`);
period: "2026 年 10 月", const result = await response.json();
name: "云泽数智大会 · 上海站", if (result.code === 200 && result.data) {
desc: "30 余位行业客户分享落地实践,现场设产品体验区。", upgradeList.value = result.data;
}, }
{ } catch (error) {
range: "11.05 ~ 11.28", console.error('获取更新内容失败:', error);
period: "2026 年 11 月", } finally {
name: "全国合作伙伴城市巡展", loading.value = false;
desc: "覆盖 12 座城市,面向合作伙伴开放产品与政策解读。", }
}, };
];
onMounted(() => {
fetchUpgradeList();
});
</script> </script>
<template> <template>
<section class="schedule"> <section class="schedule">
<div class="container schedule__inner"> <div class="container schedule__inner">
<!-- 活动时间轴 --> <!-- 平台更新时间轴 -->
<div class="schedule__panel"> <div class="schedule__panel">
<div class="schedule__head"> <div class="schedule__head">
<h2>近期活动与发布节奏</h2> <h2>近期平台更新内容</h2>
<p>2026 年 · 活动日历</p> <p>Platform Updates</p>
</div> </div>
<ul class="timeline"> <ul v-if="!loading" class="timeline">
<li <li
v-for="(item, index) in schedules" v-for="item in upgradeList"
:key="item.range" :key="item.id"
class="timeline__item" class="timeline__item"
:class="{ 'is-next': index === 0 }" :class="{ 'is-next': item.id === upgradeList[0]?.id }"
> >
<h3 class="timeline__range">{{ item.range }}</h3> <h3 class="timeline__date">{{ item.update_date }}</h3>
<!-- 节点轨道:横线由 CSS 绘制,节点天然居中 --> <!-- 节点轨道:横线由 CSS 绘制,节点天然居中 -->
<span class="timeline__rail" aria-hidden="true"> <span class="timeline__rail" aria-hidden="true">
@@ -55,27 +59,33 @@ const schedules: ScheduleItem[] = [
<i class="timeline__axis"></i> <i class="timeline__axis"></i>
</span> </span>
<p class="timeline__period">{{ item.period }}</p> <p class="timeline__name">{{ item.title }}</p>
<p class="timeline__name">{{ item.name }}</p> <p class="timeline__desc" v-html="item.content"></p>
<p class="timeline__desc">{{ item.desc }}</p> </li>
<li v-if="upgradeList.length === 0" class="timeline__empty">
暂无更新内容
</li> </li>
</ul> </ul>
<div v-else class="timeline__loading">
<el-loading-spinner></el-loading-spinner>
加载中...
</div>
</div> </div>
<!-- 活动入口 --> <!-- 管理入口 -->
<RouterLink to="/events" class="schedule-card"> <!-- <RouterLink to="/platform/softwareupgrade" class="schedule-card">
<span class="schedule-card__icon"> <span class="schedule-card__icon">
<AppIcon name="calendar" :size="28" :stroke-width="1.5" /> <AppIcon name="update" :size="28" :stroke-width="1.5" />
</span> </span>
<div class="schedule-card__text"> <div class="schedule-card__text">
<strong>市场活动</strong> <strong>更新管理</strong>
<em>参展、发布会与公开课,全年安排一站查看。</em> <em>查看和管理平台更新记录</em>
</div> </div>
<span class="schedule-card__more"> <span class="schedule-card__more">
查看全部活动 进入管理后台
<AppIcon name="arrow-right" :size="16" /> <AppIcon name="arrow-right" :size="16" />
</span> </span>
</RouterLink> </RouterLink> -->
</div> </div>
</section> </section>
</template> </template>
@@ -108,7 +118,7 @@ const schedules: ScheduleItem[] = [
&__inner { &__inner {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 360px; /* grid-template-columns: minmax(0, 1fr) 360px; */
gap: 26px; gap: 26px;
align-items: stretch; align-items: stretch;
} }
@@ -157,7 +167,7 @@ const schedules: ScheduleItem[] = [
min-width: 0; min-width: 0;
} }
&__range { &__date {
font-size: 25px; font-size: 25px;
font-weight: 700; font-weight: 700;
line-height: 36px; line-height: 36px;
@@ -213,6 +223,15 @@ const schedules: ScheduleItem[] = [
color: @ink-400; color: @ink-400;
} }
&__date {
font-size: 25px;
font-weight: 700;
line-height: 36px;
letter-spacing: 0.01em;
color: @ink-900;
white-space: nowrap;
}
&__name { &__name {
margin-top: 8px; margin-top: 8px;
font-size: 16px; font-size: 16px;
@@ -237,6 +256,26 @@ const schedules: ScheduleItem[] = [
&__item:hover &__name { &__item:hover &__name {
color: @blue-600; color: @blue-600;
} }
&__empty {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
font-size: 14px;
color: @ink-400;
}
&__loading {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
font-size: 14px;
color: @ink-500;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
} }
/* ---------------- 活动入口卡 ---------------- */ /* ---------------- 活动入口卡 ---------------- */
@@ -350,6 +389,11 @@ const schedules: ScheduleItem[] = [
line-height: 32px; line-height: 32px;
} }
&__date {
font-size: 21px;
line-height: 32px;
}
&__axis { &__axis {
margin-right: -34px; margin-right: -34px;
} }
@@ -427,6 +471,11 @@ const schedules: ScheduleItem[] = [
line-height: 28px; line-height: 28px;
} }
&__date {
font-size: 19px;
line-height: 28px;
}
&__desc { &__desc {
margin-top: 8px; margin-top: 8px;
font-size: 12.5px; font-size: 12.5px;
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import DetailPage from "@/views/common/DetailPage.vue";
defineOptions({ name: "NewsDetail" });
</script>
<template>
<DetailPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import ListPage from "@/views/common/ListPage.vue";
defineOptions({ name: "NewsList" });
</script>
<template>
<ListPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import SinglePage from "@/views/common/SinglePage.vue";
defineOptions({ name: "PartnersPage" });
</script>
<template>
<SinglePage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import SinglePage from "@/views/common/SinglePage.vue";
defineOptions({ name: "PolicyDetail" });
</script>
<template>
<SinglePage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import SinglePage from "@/views/common/SinglePage.vue";
defineOptions({ name: "PolicyList" });
</script>
<template>
<SinglePage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import DetailPage from "@/views/common/DetailPage.vue";
defineOptions({ name: "ProductsDetail" });
</script>
<template>
<DetailPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import ListPage from "@/views/common/ListPage.vue";
defineOptions({ name: "ProductsList" });
</script>
<template>
<ListPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import DetailPage from "@/views/common/DetailPage.vue";
defineOptions({ name: "SolutionsDetail" });
</script>
<template>
<DetailPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import ListPage from "@/views/common/ListPage.vue";
defineOptions({ name: "SolutionsList" });
</script>
<template>
<ListPage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import SinglePage from "@/views/common/SinglePage.vue";
defineOptions({ name: "SupportDetail" });
</script>
<template>
<SinglePage />
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import SinglePage from "@/views/common/SinglePage.vue";
defineOptions({ name: "SupportList" });
</script>
<template>
<SinglePage />
</template>