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 }} + 改密 退出 + + + + + + + + + + + + + +