feat: 实现JWT失效机制和优化订单精度

- 实现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并跳转登录页
This commit is contained in:
WrBug
2025-12-03 23:01:43 +08:00
parent 2ad558da36
commit 166bec737b
9 changed files with 157 additions and 34 deletions
@@ -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)
}
@@ -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 {
@@ -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(),
@@ -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) {
@@ -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的结果一致
// 转换为 wei6 位小数)
val makerAmount = parseUnits(finalMakerAmt, COLLATERAL_TOKEN_DECIMALS)
@@ -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)
@@ -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签发时间(毫秒时间戳)
*/
@@ -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;
+27 -9
View File
@@ -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<any>
if (data && data.code !== undefined) {
handleAuthError(data.code)
}
return response
},
(error: AxiosError<ApiResponse<any>>) => {
@@ -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)