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:
+24
-7
@@ -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的结果一致
|
||||
|
||||
// 转换为 wei(6 位小数)
|
||||
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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user