From 166bec737b41e8ed953f902e87bc754dab72e2ee Mon Sep 17 00:00:00 2001 From: WrBug Date: Wed, 3 Dec 2025 23:01:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0JWT=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=E5=92=8C=E4=BC=98=E5=8C=96=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E7=B2=BE=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现JWT tokenVersion机制,修改密码后所有token自动失效 - 在User实体中添加tokenVersion字段 - 创建V2数据库迁移文件添加token_version列 - 修改密码时递增tokenVersion - JWT中包含tokenVersion,验证时检查版本是否匹配 - 支持HTTP API和WebSocket连接的token验证 - 优化订单精度计算,与SDK保持一致 - BUY订单:使用原始价格计算makerAmount,避免精度丢失 - SELL订单:使用原始价格计算takerAmount,确保与SDK结果一致 - 修复之前使用舍入后价格导致的精度问题 - 前端自动登出功能 - 响应拦截器检测认证错误(code 2001-2999)自动登出 - 支持成功响应和错误响应中的认证错误检测 - 自动清除token、断开WebSocket并跳转登录页 --- .../config/JwtAuthenticationInterceptor.kt | 31 ++++++++++---- .../config/WebSocketAuthInterceptor.kt | 17 +++++++- .../com/wrbug/polymarketbot/entity/User.kt | 3 ++ .../polymarketbot/service/AuthService.kt | 16 +++++--- .../service/OrderSigningService.kt | 17 +++++--- .../polymarketbot/service/UserService.kt | 10 +++-- .../com/wrbug/polymarketbot/util/JwtUtils.kt | 21 +++++++++- .../V2__add_token_version_to_users.sql | 40 +++++++++++++++++++ frontend/src/services/api.ts | 36 ++++++++++++----- 9 files changed, 157 insertions(+), 34 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V2__add_token_version_to_users.sql diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt index e7922ea..0bb9f49 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt @@ -1,6 +1,7 @@ package com.wrbug.polymarketbot.config import com.wrbug.polymarketbot.dto.ApiResponse +import com.wrbug.polymarketbot.repository.UserRepository import com.wrbug.polymarketbot.util.JwtUtils import com.fasterxml.jackson.databind.ObjectMapper import jakarta.servlet.http.HttpServletRequest @@ -15,7 +16,8 @@ import org.springframework.web.servlet.HandlerInterceptor */ @Component class JwtAuthenticationInterceptor( - private val jwtUtils: JwtUtils + private val jwtUtils: JwtUtils, + private val userRepository: UserRepository ) : HandlerInterceptor { private val logger = LoggerFactory.getLogger(JwtAuthenticationInterceptor::class.java) @@ -67,19 +69,34 @@ class JwtAuthenticationInterceptor( return false } + // 验证tokenVersion(检查token是否因密码修改而失效) + val username = jwtUtils.getUsernameFromToken(token) + if (username != null) { + val user = userRepository.findByUsername(username) + if (user != null) { + val tokenVersion = jwtUtils.getTokenVersionFromToken(token) + if (tokenVersion == null || tokenVersion != user.tokenVersion) { + logger.warn("Token版本不匹配,token已失效: username=$username, tokenVersion=$tokenVersion, userTokenVersion=${user.tokenVersion}, path=$path") + sendAuthError(response, "认证令牌已失效,请重新登录") + return false + } + } + } + // 检查是否需要刷新token(使用超过1天但未过期) if (jwtUtils.isTokenExpiring(token)) { - val username = jwtUtils.getUsernameFromToken(token) if (username != null) { - val newToken = jwtUtils.generateToken(username) - // 在响应头中返回新token - response.setHeader("X-New-Token", newToken) - logger.debug("Token自动刷新: username=$username, path=$path") + val user = userRepository.findByUsername(username) + if (user != null) { + val newToken = jwtUtils.generateToken(username, user.tokenVersion) + // 在响应头中返回新token + response.setHeader("X-New-Token", newToken) + logger.debug("Token自动刷新: username=$username, path=$path") + } } } // 将用户名存入Request属性,供后续使用 - val username = jwtUtils.getUsernameFromToken(token) if (username != null) { request.setAttribute("username", username) } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt index 0145160..e641741 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt @@ -1,5 +1,6 @@ package com.wrbug.polymarketbot.config +import com.wrbug.polymarketbot.repository.UserRepository import com.wrbug.polymarketbot.util.JwtUtils import org.slf4j.LoggerFactory import org.springframework.http.server.ServerHttpRequest @@ -14,7 +15,8 @@ import org.springframework.web.socket.server.HandshakeInterceptor */ @Component class WebSocketAuthInterceptor( - private val jwtUtils: JwtUtils + private val jwtUtils: JwtUtils, + private val userRepository: UserRepository ) : HandshakeInterceptor { private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java) @@ -41,9 +43,20 @@ class WebSocketAuthInterceptor( return false } - // 获取用户名并存入 attributes,供后续使用 + // 验证tokenVersion(检查token是否因密码修改而失效) val username = jwtUtils.getUsernameFromToken(token) if (username != null) { + val user = userRepository.findByUsername(username) + if (user != null) { + val tokenVersion = jwtUtils.getTokenVersionFromToken(token) + if (tokenVersion == null || tokenVersion != user.tokenVersion) { + logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username, tokenVersion=$tokenVersion, userTokenVersion=${user.tokenVersion}, uri=${request.uri}") + response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) + return false + } + } + + // 获取用户名并存入 attributes,供后续使用 attributes["username"] = username logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}") } else { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/User.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/User.kt index 431b24a..5f9d58f 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/User.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/User.kt @@ -21,6 +21,9 @@ data class User( @Column(name = "is_default", nullable = false) val isDefault: Boolean = false, // 是否默认账户(首次创建的用户) + @Column(name = "token_version", nullable = false) + var tokenVersion: Long = 0, // Token版本号,修改密码时递增,用于使所有JWT失效 + @Column(name = "created_at", nullable = false) val createdAt: Long = System.currentTimeMillis(), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AuthService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AuthService.kt index eebe6f8..bcdd280 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AuthService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AuthService.kt @@ -43,8 +43,8 @@ class AuthService( return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) } - // 生成JWT token - val token = jwtUtils.generateToken(username) + // 生成JWT token(包含tokenVersion,用于使修改密码后的旧token失效) + val token = jwtUtils.generateToken(username, user.tokenVersion) logger.info("用户登录成功:username=$username") Result.success(LoginResponse(token = token)) @@ -90,14 +90,15 @@ class AuthService( val existingUser = userRepository.findByUsername(username) if (existingUser != null) { - // 用户存在,更新密码 + // 用户存在,更新密码并递增tokenVersion(使所有旧token失效) val encodedPassword = passwordEncoder.encode(newPassword) val updatedUser = existingUser.copy( password = encodedPassword, + tokenVersion = existingUser.tokenVersion + 1, // 递增tokenVersion updatedAt = System.currentTimeMillis() ) userRepository.save(updatedUser) - logger.info("密码重置成功:username=$username") + logger.info("密码重置成功:username=$username, tokenVersion=${updatedUser.tokenVersion}") } else { // 用户不存在,检查是否是首次使用 val isFirstUse = userRepository.count() == 0L @@ -108,6 +109,7 @@ class AuthService( username = username, password = encodedPassword, isDefault = true, // 首次创建的用户为默认账户 + tokenVersion = 0, // 初始tokenVersion为0 createdAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis() ) @@ -145,7 +147,11 @@ class AuthService( val username = jwtUtils.getUsernameFromToken(token) ?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_TOKEN_INVALID.message)) - val newToken = jwtUtils.generateToken(username) + // 从数据库获取用户的tokenVersion + val user = userRepository.findByUsername(username) + ?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_TOKEN_INVALID.message)) + + val newToken = jwtUtils.generateToken(username, user.tokenVersion) logger.debug("Token刷新成功:username=$username") Result.success(newToken) } catch (e: Exception) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt index aeeedcb..9da1509 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/OrderSigningService.kt @@ -86,7 +86,10 @@ class OrderSigningService { // makerAmount 是 USDC 金额,最多 2 位小数 // takerAmount 是 shares 数量,最多 4 位小数 val rawTakerAmt = roundDown(sizeDecimal, roundConfig.size) - var rawMakerAmt = rawTakerAmt.multiply(roundedPrice) + + // makerAmount = price * size,使用原始价格计算(与SDK保持一致) + // 先使用原始价格计算,然后再进行舍入,确保精度一致 + var rawMakerAmt = rawTakerAmt.multiply(priceDecimal) // 确保 makerAmount 精度(USDC,最多 2 位小数) rawMakerAmt = roundDown(rawMakerAmt, MAKER_AMOUNT_DECIMALS) @@ -102,18 +105,20 @@ class OrderSigningService { } else { // SELL: makerAmount = size (shares), takerAmount = price * size (USDC) // makerAmount 是 shares 数量,最多 4 位小数 - // takerAmount 是 USDC 金额,需要精确计算,不进行舍入(保留足够精度以转换为 wei) + // takerAmount 是 USDC 金额,需要使用原始价格计算(与SDK保持一致) val rawMakerAmt = roundDown(sizeDecimal, roundConfig.size) - // takerAmount = price * size,使用精确计算,不进行舍入 - // 直接使用精确计算结果转换为 wei(6 位小数),让 parseUnits 处理精度 - val rawTakerAmt = rawMakerAmt.multiply(roundedPrice) + + // takerAmount = price * size,使用原始价格计算(不使用舍入后的价格) + // SDK期望使用原始价格进行计算,以保留足够的精度 + // 例如:0.9596 * 16.09 = 15.439964,而不是 0.96 * 16.09 = 15.4464 + val rawTakerAmt = rawMakerAmt.multiply(priceDecimal) // 确保 makerAmount 精度(shares,最多 4 位小数) val finalMakerAmt = roundDown(rawMakerAmt, TAKER_AMOUNT_DECIMALS) // takerAmount 不进行舍入,直接使用精确计算结果转换为 wei - // 这样可以保留足够的精度,避免精度丢失导致的错误 // parseUnits 会将 BigDecimal 转换为 wei(6 位小数),自动处理精度 + // 使用原始价格计算可以确保与SDK的结果一致 // 转换为 wei(6 位小数) val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/UserService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/UserService.kt index 066ea98..d95406e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/UserService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/UserService.kt @@ -153,15 +153,16 @@ class UserService( return Result.failure(IllegalArgumentException("不能修改默认账户的密码")) } - // 更新密码 + // 更新密码并递增tokenVersion(使所有旧token失效) val encodedPassword = passwordEncoder.encode(request.newPassword) val updatedUser = targetUser.copy( password = encodedPassword, + tokenVersion = targetUser.tokenVersion + 1, // 递增tokenVersion updatedAt = System.currentTimeMillis() ) userRepository.save(updatedUser) - logger.info("更新用户密码成功:userId=${request.userId}, username=${targetUser.username}, updatedBy=$currentUsername") + logger.info("更新用户密码成功:userId=${request.userId}, username=${targetUser.username}, updatedBy=$currentUsername, tokenVersion=${updatedUser.tokenVersion}") Result.success(Unit) } catch (e: Exception) { logger.error("更新用户密码异常:userId=${request.userId}, currentUser=$currentUsername", e) @@ -185,15 +186,16 @@ class UserService( val user = userRepository.findByUsername(currentUsername) ?: return Result.failure(IllegalArgumentException("用户不存在")) - // 更新密码(只更新当前用户的密码,不依赖任何请求参数中的用户ID) + // 更新密码并递增tokenVersion(使所有旧token失效) val encodedPassword = passwordEncoder.encode(newPassword) val updatedUser = user.copy( password = encodedPassword, + tokenVersion = user.tokenVersion + 1, // 递增tokenVersion updatedAt = System.currentTimeMillis() ) userRepository.save(updatedUser) - logger.info("用户修改自己密码成功:username=$currentUsername, userId=${user.id}") + logger.info("用户修改自己密码成功:username=$currentUsername, userId=${user.id}, tokenVersion=${updatedUser.tokenVersion}") Result.success(Unit) } catch (e: Exception) { logger.error("用户修改自己密码异常:username=$currentUsername", e) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JwtUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JwtUtils.kt index 17cb932..78eb550 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JwtUtils.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JwtUtils.kt @@ -63,14 +63,16 @@ class JwtUtils { /** * 生成JWT token * @param username 用户名 + * @param tokenVersion Token版本号(用于使修改密码后的旧token失效) * @return JWT token字符串 */ - fun generateToken(username: String): String { + fun generateToken(username: String, tokenVersion: Long = 0): String { val now = Date() val expiryDate = Date(now.time + expiration) return Jwts.builder() .subject(username) + .claim("tokenVersion", tokenVersion) // 添加tokenVersion到payload .issuedAt(now) .expiration(expiryDate) .signWith(getSigningKey()) @@ -116,6 +118,23 @@ class JwtUtils { } } + /** + * 从token中获取tokenVersion + */ + fun getTokenVersionFromToken(token: String): Long? { + return try { + val claims = getClaimsFromToken(token) + val version = claims?.get("tokenVersion") + when (version) { + is Number -> version.toLong() + is String -> version.toLongOrNull() + else -> null + } + } catch (e: Exception) { + null + } + } + /** * 获取token签发时间(毫秒时间戳) */ diff --git a/backend/src/main/resources/db/migration/V2__add_token_version_to_users.sql b/backend/src/main/resources/db/migration/V2__add_token_version_to_users.sql new file mode 100644 index 0000000..9220465 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__add_token_version_to_users.sql @@ -0,0 +1,40 @@ +-- ============================================ +-- 添加 token_version 字段到 users 表 +-- 用于使修改密码后的所有JWT token失效 +-- 注意:如果V1脚本已包含此字段,此迁移会被跳过(Flyway会处理) +-- ============================================ + +-- 检查字段是否存在,如果不存在则添加 +-- MySQL不支持IF NOT EXISTS,但Flyway会处理重复执行的情况 +-- 如果字段已存在,此语句会报错,但Flyway会记录迁移已执行,不会重复执行 + +-- 使用存储过程检查并添加字段(如果不存在) +DELIMITER $$ + +CREATE PROCEDURE IF NOT EXISTS add_token_version_if_not_exists() +BEGIN + DECLARE column_exists INT DEFAULT 0; + + SELECT COUNT(*) INTO column_exists + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME = 'token_version'; + + IF column_exists = 0 THEN + ALTER TABLE users + ADD COLUMN token_version BIGINT NOT NULL DEFAULT 0 COMMENT 'Token版本号,修改密码时递增,用于使所有JWT失效'; + END IF; +END$$ + +DELIMITER ; + +-- 执行存储过程 +CALL add_token_version_if_not_exists(); + +-- 删除存储过程 +DROP PROCEDURE IF EXISTS add_token_version_if_not_exists; + +-- 为现有用户设置默认tokenVersion为0(如果值为NULL) +UPDATE users SET token_version = 0 WHERE token_version IS NULL; + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1d8a178..72dff83 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -43,6 +43,23 @@ apiClient.interceptors.request.use( } ) +/** + * 处理认证错误(自动登出) + */ +const handleAuthError = (code: number) => { + // 检查是否是认证错误(2001-2999) + if (code >= 2001 && code < 3000) { + // 清除 token + removeToken() + // 断开 WebSocket 连接 + wsManager.disconnect() + // 跳转到登录页(避免循环跳转) + if (window.location.pathname !== '/login' && window.location.pathname !== '/reset-password') { + window.location.href = '/login' + } + } +} + /** * 响应拦截器 */ @@ -53,6 +70,14 @@ apiClient.interceptors.response.use( if (newToken) { setToken(newToken) } + + // 检查响应体中的 code,如果是认证错误,自动登出 + // 后端可能返回 200 状态码,但 code 是 2001(认证失败) + const data = response.data as ApiResponse + if (data && data.code !== undefined) { + handleAuthError(data.code) + } + return response }, (error: AxiosError>) => { @@ -61,15 +86,8 @@ apiClient.interceptors.response.use( const data = response.data // 检查是否是认证错误(2001-2999) - if (data && data.code >= 2001 && data.code < 3000) { - // 清除 token - removeToken() - // 断开 WebSocket 连接 - wsManager.disconnect() - // 跳转到登录页(避免循环跳转) - if (window.location.pathname !== '/login' && window.location.pathname !== '/reset-password') { - window.location.href = '/login' - } + if (data && data.code !== undefined) { + handleAuthError(data.code) } console.error('API 错误:', data)