From 9338fcbd5fd8502688824e782c0acc1db9a7bc04 Mon Sep 17 00:00:00 2001
From: wanghanlin <1533525126@qq.com>
Date: Tue, 25 Aug 2026 11:37:18 +0800
Subject: [PATCH] =?UTF-8?q?feat(security):=20JWT=20token=5Fversion=20?=
=?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=92=A4=E9=94=80=E4=B8=8E=E7=99=BB?=
=?UTF-8?q?=E5=BD=95=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 token_version 服务端撤销机制:改密/禁用/角色变更/登出时自增,使已签发 token 立即失效
- refresh token 从 localStorage 迁移到 httpOnly Cookie,access token 有效期缩短至 15 分钟
- 新增登录失败锁定(LoginAttemptService)与密码强度校验(PasswordValidator)
- SDK JWT 密钥支持环境变量注入,使用默认密钥时告警
修复:
- assignRoles 角色未变化仍自增 token_version,导致用户新建用户时 token 被误失效而跳转登录
- 角色 ID 解析兼容前端字符串传输,避免雪花 ID 精度丢失导致 ClassCastException
- updateFill 改用 setFieldValByName,修复 update_time 未自动填充的问题
---
frontend/src/App.vue | 30 +--
frontend/src/api/auth.ts | 16 +-
frontend/src/api/request.ts | 66 +++++-
frontend/src/layouts/MainLayout.vue | 4 +-
frontend/src/layouts/Topbar.vue | 91 +++++++-
frontend/src/stores/auth.ts | 18 +-
frontend/src/types/models.ts | 6 +-
frontend/src/utils/sse.ts | 4 +-
frontend/src/utils/token.ts | 21 +-
frontend/src/views/LoginPage.vue | 5 +-
.../wok/supportbot/auth/AuthController.java | 201 ++++++++++++++---
.../com/wok/supportbot/config/CorsConfig.java | 21 +-
.../supportbot/config/DatabaseInitConfig.java | 51 ++++-
.../controller/SysUserController.java | 32 ++-
.../com/wok/supportbot/entity/SysUser.java | 4 +
.../handler/MyMetaObjectHandler.java | 5 +-
.../supportbot/security/JwtAuthFilter.java | 43 ++--
.../supportbot/security/JwtTokenProvider.java | 81 +++++--
.../security/LoginAttemptService.java | 95 ++++++++
.../security/PasswordValidator.java | 48 ++++
.../supportbot/security/SdkAuthFilter.java | 39 +++-
.../security/SdkJwtTokenProvider.java | 18 +-
.../supportbot/security/SecurityConfig.java | 17 +-
.../supportbot/service/SysUserService.java | 63 +++++-
src/main/resources/application-dev.yml | 15 +-
src/main/resources/application-prod.yml | 16 +-
src/main/resources/application.yml | 28 ++-
src/main/resources/init-database.sql | 210 ++++++++++++++++--
src/main/resources/static/sdk/test.html | 8 +-
src/main/resources/support-bot.sql | 16 +-
30 files changed, 1067 insertions(+), 205 deletions(-)
create mode 100644 src/main/java/com/wok/supportbot/security/LoginAttemptService.java
create mode 100644 src/main/java/com/wok/supportbot/security/PasswordValidator.java
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 3679acc..2c75068 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -6,11 +6,11 @@
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts
index 8bec31d..ff6cbb7 100644
--- a/frontend/src/api/auth.ts
+++ b/frontend/src/api/auth.ts
@@ -6,9 +6,19 @@ export function login(username: string, password: string): Promise r.data)
}
-/** 刷新 Token */
-export function refreshToken(refreshToken: string): Promise> {
- return request.post('/auth/refresh', { refreshToken }).then(r => r.data)
+/** 刷新 Token(refresh token 通过 httpOnly Cookie 自动携带) */
+export function refreshToken(): Promise> {
+ return request.post('/auth/refresh').then(r => r.data)
+}
+
+/** 退出登录(清除服务端 refresh token cookie) */
+export function logout(): Promise> {
+ return request.post('/auth/logout').then(r => r.data)
+}
+
+/** 当前登录用户自助修改密码 */
+export function changePassword(oldPassword: string, newPassword: string): Promise> {
+ return request.put('/auth/password', { oldPassword, newPassword }).then(r => r.data)
}
/** 获取当前登录用户信息 */
diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts
index d38101d..49d938e 100644
--- a/frontend/src/api/request.ts
+++ b/frontend/src/api/request.ts
@@ -3,13 +3,15 @@
* 替代旧 api.js 中的 fetch 封装
*/
import axios from 'axios'
-import { getToken } from '@/utils/token'
+import { getToken, setToken, clearTokens } from '@/utils/token'
/** 同源部署,API 基址为空 */
const request = axios.create({
baseURL: '',
timeout: 30000,
headers: { 'Content-Type': 'application/json' },
+ // 允许跨域携带 Cookie(refresh token 使用 httpOnly Cookie)
+ withCredentials: true,
})
/** 请求拦截器:自动附加 Bearer Token */
@@ -22,18 +24,64 @@ request.interceptors.request.use((config) => {
})
/**
- * 响应拦截器:401 触发 auth:unauthorized 事件
- * 与旧版 app.js 中的 Token 刷新逻辑一致
+ * Token 刷新锁与等待队列:
+ * - 多个请求并发 401 时,只有第一个发起 /auth/refresh,其余挂起等待新 token 后重试。
+ * - 刷新成功后重试原请求,调用方无感知。
+ */
+let isRefreshing = false
+let pendingQueue: Array<(token: string) => void> = []
+
+/**
+ * 响应拦截器:401 自动刷新 access token 并重试原请求
+ * 注意:/auth/login 与 /auth/refresh 自身的 401 不做刷新处理,由调用方自行处理,避免递归。
*/
request.interceptors.response.use(
(response) => response,
- (error) => {
- if (error.response?.status === 401) {
- localStorage.removeItem('sb_token')
- localStorage.removeItem('sb_user')
- window.dispatchEvent(new Event('auth:unauthorized'))
+ async (error) => {
+ const { config, response } = error
+ const url: string = config?.url || ''
+
+ if (response?.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
+ // 已有刷新在进行中,挂起当前请求,等待新 token 后重试
+ if (isRefreshing) {
+ return new Promise((resolve, reject) => {
+ pendingQueue.push((token: string) => {
+ if (token) {
+ resolve(request(config))
+ } else {
+ reject(new Error(response?.data?.message || error.message || '网络错误'))
+ }
+ })
+ })
+ }
+
+ // 发起刷新(request 的 url 为 /auth/refresh,其 401 会被上方判断跳过,不会递归)
+ isRefreshing = true
+ try {
+ const res = await request.post('/auth/refresh')
+ const newToken = res.data?.data?.accessToken
+ if (newToken) {
+ setToken(newToken)
+ // 放行所有挂起请求(请求拦截器会自动附加新 token)
+ pendingQueue.forEach((cb) => cb(newToken))
+ pendingQueue = []
+ // 重试当前请求
+ return request(config)
+ }
+ throw new Error('刷新令牌失败')
+ } catch (refreshErr) {
+ // 刷新失败:清除本地凭证并触发全局登出
+ clearTokens()
+ pendingQueue.forEach((cb) => cb(''))
+ pendingQueue = []
+ window.dispatchEvent(new Event('auth:unauthorized'))
+ return Promise.reject(new Error(response?.data?.message || error.message || '网络错误'))
+ } finally {
+ isRefreshing = false
+ }
}
- const message = error.response?.data?.message || error.message || '网络错误'
+
+ const message = response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(message))
}
)
diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue
index 73e60f2..b7e250d 100644
--- a/frontend/src/layouts/MainLayout.vue
+++ b/frontend/src/layouts/MainLayout.vue
@@ -50,8 +50,8 @@ const mobileMenuOpen = ref(false)
// 提供给子组件全局弹窗能力
provide('openDocDetail', (docId: string) => dialog.openDetail(docId))
-function handleLogout() {
- auth.logout()
+async function handleLogout() {
+ await auth.doLogout()
toast('已退出登录', 'info')
router.push('/chat')
}
diff --git a/frontend/src/layouts/Topbar.vue b/frontend/src/layouts/Topbar.vue
index bfea6e0..ea173e8 100644
--- a/frontend/src/layouts/Topbar.vue
+++ b/frontend/src/layouts/Topbar.vue
@@ -13,21 +13,108 @@
{{ displayName }}
{{ r }}
+ 改密
退出
+
+
+
+
+
+
+
+
+
+
+
+
+
+