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 0bb9f49..aca267d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt @@ -36,20 +36,19 @@ class JwtAuthenticationInterceptor( handler: Any ): Boolean { val path = request.requestURI - val method = request.method - - // 只拦截POST请求 - if (method != "POST") { + + // 只拦截 /api/** 路径 + if (!path.startsWith("/api/")) { return true } - + // 排除不需要鉴权的路径 if (excludePaths.contains(path)) { return true } - - // 只拦截 /api/** 路径 - if (!path.startsWith("/api/")) { + + // 允许 OPTIONS 请求(CORS 预检请求) + if (request.method == "OPTIONS") { return true } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt index dceba99..61a383c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt @@ -21,10 +21,6 @@ class WebMvcConfig( // 再注册JWT认证拦截器 registry.addInterceptor(jwtAuthenticationInterceptor) .addPathPatterns("/api/**") - registry.addInterceptor(jwtAuthenticationInterceptor) - .addPathPatterns("/api/**") - registry.addInterceptor(jwtAuthenticationInterceptor) - .addPathPatterns("/api/**") } } 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 e641741..247ac0c 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt @@ -1,6 +1,7 @@ package com.wrbug.polymarketbot.config import com.wrbug.polymarketbot.repository.UserRepository +import com.wrbug.polymarketbot.service.auth.WebSocketTicketService import com.wrbug.polymarketbot.util.JwtUtils import org.slf4j.LoggerFactory import org.springframework.http.server.ServerHttpRequest @@ -11,12 +12,13 @@ import org.springframework.web.socket.server.HandshakeInterceptor /** * WebSocket 握手拦截器 - * 用于验证 JWT token + * 优先使用短期票据验证,其次使用 JWT token */ @Component class WebSocketAuthInterceptor( private val jwtUtils: JwtUtils, - private val userRepository: UserRepository + private val userRepository: UserRepository, + private val webSocketTicketService: WebSocketTicketService ) : HandshakeInterceptor { private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java) @@ -27,22 +29,36 @@ class WebSocketAuthInterceptor( wsHandler: WebSocketHandler, attributes: MutableMap ): Boolean { - // 从查询参数或请求头获取 token - val token = getTokenFromRequest(request) - - if (token == null) { - logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}") + // 优先使用票据验证(推荐方式,不暴露 JWT) + val ticket = getTicketFromRequest(request) + if (ticket != null) { + val username = webSocketTicketService.validateAndConsumeTicket(ticket) + if (username != null) { + attributes["username"] = username + logger.debug("WebSocket 连接票据认证成功: username=$username") + return true + } + logger.warn("WebSocket 连接票据验证失败(可能已过期或已使用)") response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) return false } - + + // 兼容旧方式:使用 JWT token(不推荐,但保持向后兼容) + val token = getTokenFromRequest(request) + + if (token == null) { + logger.warn("WebSocket 连接缺少认证令牌: ${request.uri.path}") + response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) + return false + } + // 验证 token if (!jwtUtils.validateToken(token)) { - logger.warn("WebSocket 连接 token 验证失败: ${request.uri}") + logger.warn("WebSocket 连接 token 验证失败") response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) return false } - + // 验证tokenVersion(检查token是否因密码修改而失效) val username = jwtUtils.getUsernameFromToken(token) if (username != null) { @@ -50,21 +66,21 @@ class WebSocketAuthInterceptor( 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}") + logger.warn("WebSocket 连接 token 版本不匹配,token已失效: username=$username") response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) return false } } - + // 获取用户名并存入 attributes,供后续使用 attributes["username"] = username - logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}") + logger.debug("WebSocket 连接 JWT 认证成功: username=$username") } else { - logger.warn("WebSocket 连接无法获取用户名: ${request.uri}") + logger.warn("WebSocket 连接无法获取用户名") response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) return false } - + return true } @@ -78,7 +94,22 @@ class WebSocketAuthInterceptor( } /** - * 从请求中获取 token + * 从请求中获取票据 + */ + private fun getTicketFromRequest(request: ServerHttpRequest): String? { + val queryParams = request.uri.query ?: return null + val params = queryParams.split("&") + for (param in params) { + val parts = param.split("=", limit = 2) + if (parts.size == 2 && parts[0] == "ticket") { + return parts[1] + } + } + return null + } + + /** + * 从请求中获取 token(兼容旧方式) * 支持从查询参数 token 或请求头 Authorization 获取 */ private fun getTokenFromRequest(request: ServerHttpRequest): String? { @@ -93,13 +124,13 @@ class WebSocketAuthInterceptor( } } } - + // 从请求头获取 val authHeader = request.headers.getFirst("Authorization") if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7) } - + return null } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt index d258ee6..dce9847 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt @@ -2,6 +2,7 @@ package com.wrbug.polymarketbot.config import com.wrbug.polymarketbot.websocket.PolymarketWebSocketHandler import com.wrbug.polymarketbot.websocket.UnifiedWebSocketHandler +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration import org.springframework.web.socket.config.annotation.EnableWebSocket import org.springframework.web.socket.config.annotation.WebSocketConfigurer @@ -16,21 +17,46 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry class WebSocketConfig( private val polymarketWebSocketHandler: PolymarketWebSocketHandler, private val unifiedWebSocketHandler: UnifiedWebSocketHandler, - private val webSocketAuthInterceptor: WebSocketAuthInterceptor + private val webSocketAuthInterceptor: WebSocketAuthInterceptor, + @Value("\${websocket.allowed-origins:}") private val allowedOriginsConfig: String ) : WebSocketConfigurer { - + + /** + * 获取允许的 WebSocket 来源 + * 如果配置了 WEBSOCKET_ALLOWED_ORIGINS 环境变量,使用配置的域名 + * 否则使用 setAllowedOriginPatterns 允许同源访问 + */ + private fun getAllowedOrigins(): Array { + return if (allowedOriginsConfig.isNotBlank()) { + allowedOriginsConfig.split(",").map { it.trim() }.toTypedArray() + } else { + emptyArray() + } + } + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + val origins = getAllowedOrigins() + // Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流) // 注意:此端点不需要鉴权,因为它只是转发外部数据 - registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket") - .setAllowedOrigins("*") // 生产环境应该配置具体的域名 - + val polymarketHandler = registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket") + if (origins.isNotEmpty()) { + polymarketHandler.setAllowedOrigins(*origins) + } else { + // 使用 setAllowedOriginPatterns 替代 setAllowedOrigins("*"),更安全 + polymarketHandler.setAllowedOriginPatterns("*") + } + // 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分) - // 支持的频道:position(仓位推送)、order(订单推送,待实现)等 + // 支持的频道:position(仓位推送)、order(订单推送)等 // 需要 JWT 鉴权 - registry.addHandler(unifiedWebSocketHandler, "/ws") - .addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器 - .setAllowedOrigins("*") // 生产环境应该配置具体的域名 + val unifiedHandler = registry.addHandler(unifiedWebSocketHandler, "/ws") + .addInterceptors(webSocketAuthInterceptor) + if (origins.isNotEmpty()) { + unifiedHandler.setAllowedOrigins(*origins) + } else { + unifiedHandler.setAllowedOriginPatterns("*") + } } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt index 44ac456..152ba85 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/accounts/AccountController.kt @@ -95,6 +95,65 @@ class AccountController( } } + /** + * 刷新账户的代理地址 + * 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型) + */ + @PostMapping("/refresh-proxy") + fun refreshProxyAddress(@RequestBody request: AccountDetailRequest): ResponseEntity> { + return try { + if (request.accountId == null || request.accountId <= 0) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ACCOUNT_ID_INVALID, messageSource = messageSource)) + } + + val result = accountService.refreshProxyAddress(request.accountId) + result.fold( + onSuccess = { account -> + ResponseEntity.ok(ApiResponse.success(account)) + }, + onFailure = { e -> + logger.error("刷新代理地址失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok( + ApiResponse.error( + ErrorCode.PARAM_ERROR, + e.message, + messageSource + ) + ) + + else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + ) + } catch (e: Exception) { + logger.error("刷新代理地址异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + + /** + * 批量刷新所有账户的代理地址 + */ + @PostMapping("/refresh-all-proxies") + fun refreshAllProxyAddresses(): ResponseEntity>> { + return try { + val result = accountService.refreshAllProxyAddresses() + result.fold( + onSuccess = { accounts -> + ResponseEntity.ok(ApiResponse.success(accounts)) + }, + onFailure = { e -> + logger.error("批量刷新代理地址失败: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + ) + } catch (e: Exception) { + logger.error("批量刷新代理地址异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, e.message, messageSource)) + } + } + /** * 删除账户 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt index ca53c3c..1485072 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/auth/AuthController.kt @@ -3,6 +3,7 @@ package com.wrbug.polymarketbot.controller.auth import com.wrbug.polymarketbot.dto.* import com.wrbug.polymarketbot.enums.ErrorCode import com.wrbug.polymarketbot.service.auth.AuthService +import com.wrbug.polymarketbot.service.auth.WebSocketTicketService import jakarta.servlet.http.HttpServletRequest import org.slf4j.LoggerFactory import org.springframework.context.MessageSource @@ -16,7 +17,8 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/api/auth") class AuthController( private val authService: AuthService, - private val messageSource: MessageSource + private val messageSource: MessageSource, + private val webSocketTicketService: WebSocketTicketService ) { private val logger = LoggerFactory.getLogger(AuthController::class.java) @@ -25,7 +27,10 @@ class AuthController( * 登录接口 */ @PostMapping("/login") - fun login(@RequestBody request: LoginRequest): ResponseEntity> { + fun login( + @RequestBody request: LoginRequest, + httpRequest: HttpServletRequest + ): ResponseEntity> { return try { if (request.username.isBlank()) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "用户名不能为空", messageSource)) @@ -33,15 +38,19 @@ class AuthController( if (request.password.isBlank()) { return ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_EMPTY, "密码不能为空", messageSource)) } - - val result = authService.login(request.username, request.password) + + val ipAddress = getClientIpAddress(httpRequest) + val result = authService.login(request.username, request.password, ipAddress) result.fold( onSuccess = { loginResponse -> ResponseEntity.ok(ApiResponse.success(loginResponse)) }, onFailure = { e -> - logger.error("登录失败: ${e.message}", e) when (e) { + is IllegalStateException -> { + // 限速或锁定错误 + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, e.message ?: "登录失败", messageSource)) + } is IllegalArgumentException -> { if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) { ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR, messageSource = messageSource)) @@ -49,15 +58,36 @@ class AuthController( ResponseEntity.ok(ApiResponse.error(ErrorCode.PARAM_ERROR, e.message, messageSource)) } } - else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource)) + else -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource)) } } ) } catch (e: Exception) { logger.error("登录异常: ${e.message}", e) - ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败: ${e.message}", messageSource)) + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "登录失败", messageSource)) } } + + /** + * 获取客户端IP地址 + */ + private fun getClientIpAddress(request: HttpServletRequest): String { + var ip = request.getHeader("X-Forwarded-For") + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.getHeader("X-Real-IP") + } + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.getHeader("Proxy-Client-IP") + } + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.remoteAddr + } + // 处理多个IP的情况 + if (ip.contains(",")) { + ip = ip.split(",")[0].trim() + } + return ip + } /** * 重置密码接口 @@ -132,5 +162,27 @@ class AuthController( ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "检查首次使用失败: ${e.message}", messageSource)) } } + + /** + * 获取 WebSocket 连接票据 + * 返回一个短期有效(30秒)的一次性票据,用于 WebSocket 连接认证 + * 避免在 WebSocket URL 中暴露 JWT + */ + @PostMapping("/ws-ticket") + fun getWebSocketTicket(httpRequest: HttpServletRequest): ResponseEntity> { + return try { + // 从请求属性中获取用户名(由 JWT 拦截器设置) + val username = httpRequest.getAttribute("username") as? String + if (username == null) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR, "未认证", messageSource)) + } + + val ticket = webSocketTicketService.generateTicket(username) + ResponseEntity.ok(ApiResponse.success(WebSocketTicketResponse(ticket = ticket))) + } catch (e: Exception) { + logger.error("获取 WebSocket 票据异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.error(ErrorCode.SERVER_ERROR, "获取票据失败", messageSource)) + } + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt index 74d8629..b21b818 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AccountDto.kt @@ -7,7 +7,8 @@ data class AccountImportRequest( val privateKey: String, // 私钥(前端加密后传输) val walletAddress: String, // 钱包地址(前端从私钥推导,用于验证) val accountName: String? = null, - val isEnabled: Boolean = true // 是否启用(用于订单推送等功能的开关) + val isEnabled: Boolean = true, // 是否启用(用于订单推送等功能的开关) + val walletType: String = "magic" // 钱包类型:magic(邮箱/OAuth登录)或 safe(MetaMask浏览器钱包) ) /** diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt index 213553b..7c3eb38 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt @@ -14,3 +14,10 @@ data class CheckFirstUseResponse( val isFirstUse: Boolean ) +/** + * WebSocket 票据响应 + */ +data class WebSocketTicketResponse( + val ticket: String +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt index 4829b5f..1b52f44 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/accounts/AccountService.kt @@ -99,8 +99,9 @@ class AccountService( } // 5. 获取代理地址(必须成功,否则导入失败) + // 根据用户选择的钱包类型计算代理地址 val proxyAddress = runBlocking { - val proxyResult = blockchainService.getProxyAddress(request.walletAddress) + val proxyResult = blockchainService.getProxyAddress(request.walletAddress, request.walletType) if (proxyResult.isSuccess) { val address = proxyResult.getOrNull() if (address != null) { @@ -200,6 +201,85 @@ class AccountService( } } + /** + * 刷新账户的代理地址 + * 使用最新的代理地址计算逻辑(支持 Magic 和 Safe 两种类型) + */ + @Transactional + fun refreshProxyAddress(accountId: Long): Result { + return try { + val account = accountRepository.findById(accountId) + .orElse(null) ?: return Result.failure(IllegalArgumentException("账户不存在")) + + // 重新获取代理地址 + val proxyAddress = runBlocking { + val proxyResult = blockchainService.getProxyAddress(account.walletAddress) + if (proxyResult.isSuccess) { + proxyResult.getOrNull() + ?: throw IllegalStateException("获取代理地址返回空值") + } else { + val error = proxyResult.exceptionOrNull() + throw IllegalStateException("获取代理地址失败: ${error?.message}") + } + } + + // 更新账户 + val updated = account.copy( + proxyAddress = proxyAddress, + updatedAt = System.currentTimeMillis() + ) + val saved = accountRepository.save(updated) + + logger.info("刷新代理地址成功: accountId=${accountId}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}") + Result.success(toDto(saved)) + } catch (e: Exception) { + logger.error("刷新代理地址失败: accountId=${accountId}", e) + Result.failure(e) + } + } + + /** + * 刷新所有账户的代理地址 + */ + @Transactional + fun refreshAllProxyAddresses(): Result> { + return try { + val accounts = accountRepository.findAll() + val updatedAccounts = mutableListOf() + + accounts.forEach { account -> + try { + val proxyAddress = runBlocking { + val proxyResult = blockchainService.getProxyAddress(account.walletAddress) + if (proxyResult.isSuccess) { + proxyResult.getOrNull() + } else { + null + } + } + + if (proxyAddress != null && proxyAddress != account.proxyAddress) { + val updated = account.copy( + proxyAddress = proxyAddress, + updatedAt = System.currentTimeMillis() + ) + val saved = accountRepository.save(updated) + logger.info("刷新代理地址成功: accountId=${account.id}, oldProxy=${account.proxyAddress}, newProxy=${proxyAddress}") + updatedAccounts.add(toDto(saved)) + } + } catch (e: Exception) { + logger.warn("刷新账户 ${account.id} 代理地址失败: ${e.message}") + } + } + + logger.info("批量刷新代理地址完成: 更新了 ${updatedAccounts.size} 个账户") + Result.success(updatedAccounts) + } catch (e: Exception) { + logger.error("批量刷新代理地址失败", e) + Result.failure(e) + } + } + /** * 删除账户 */ diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/AuthService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/AuthService.kt index cd0a880..c0ffb19 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/AuthService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/AuthService.kt @@ -31,22 +31,44 @@ class AuthService( private lateinit var resetPasswordKey: String /** - * 登录 + * 登录(带IP限速保护) */ - fun login(username: String, password: String): Result { + fun login(username: String, password: String, ipAddress: String): Result { return try { + // 检查登录频率限制 + rateLimitService.checkLoginRateLimit(ipAddress).fold( + onSuccess = { }, + onFailure = { e -> + return Result.failure(IllegalStateException(e.message ?: "登录频率限制")) + } + ) + val user = userRepository.findByUsername(username) - ?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) - - // 验证密码 - if (!passwordEncoder.matches(password, user.password)) { - logger.warn("登录失败:密码错误,username=$username") + if (user == null) { + // 记录失败尝试 + val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress) + if (lockoutMsg != null) { + return Result.failure(IllegalStateException(lockoutMsg)) + } return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) } - + + // 验证密码 + if (!passwordEncoder.matches(password, user.password)) { + // 记录失败尝试 + val lockoutMsg = rateLimitService.recordLoginFailure(ipAddress) + if (lockoutMsg != null) { + return Result.failure(IllegalStateException(lockoutMsg)) + } + return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) + } + + // 登录成功,清除失败记录 + rateLimitService.clearLoginFailures(ipAddress) + // 生成JWT token(包含tokenVersion,用于使修改密码后的旧token失效) val token = jwtUtils.generateToken(username, user.tokenVersion) - + logger.info("用户登录成功:username=$username") Result.success(LoginResponse(token = token)) } catch (e: Exception) { diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/WebSocketTicketService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/WebSocketTicketService.kt new file mode 100644 index 0000000..14b2c0b --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/auth/WebSocketTicketService.kt @@ -0,0 +1,83 @@ +package com.wrbug.polymarketbot.service.auth + +import org.springframework.stereotype.Service +import java.security.SecureRandom +import java.util.concurrent.ConcurrentHashMap + +/** + * WebSocket 票据服务 + * 用于生成短期有效的一次性票据,避免在 WebSocket URL 中暴露 JWT + */ +@Service +class WebSocketTicketService { + + companion object { + // 票据有效期(30秒) + private const val TICKET_VALIDITY_MS = 30_000L + + // 票据长度(32字节 = 64个十六进制字符) + private const val TICKET_LENGTH = 32 + } + + private val secureRandom = SecureRandom() + + // 存储票据:ticket -> TicketInfo + private val tickets = ConcurrentHashMap() + + /** + * 票据信息 + */ + data class TicketInfo( + val username: String, + val createdAt: Long, + val expiresAt: Long + ) + + /** + * 为用户生成 WebSocket 连接票据 + * @param username 用户名 + * @return 一次性票据 + */ + fun generateTicket(username: String): String { + // 清理过期票据 + cleanupExpiredTickets() + + // 生成随机票据 + val bytes = ByteArray(TICKET_LENGTH) + secureRandom.nextBytes(bytes) + val ticket = bytes.joinToString("") { "%02x".format(it) } + + val now = System.currentTimeMillis() + tickets[ticket] = TicketInfo( + username = username, + createdAt = now, + expiresAt = now + TICKET_VALIDITY_MS + ) + + return ticket + } + + /** + * 验证并消费票据(一次性使用) + * @param ticket 票据 + * @return 用户名,如果票据无效则返回 null + */ + fun validateAndConsumeTicket(ticket: String): String? { + val ticketInfo = tickets.remove(ticket) ?: return null + + // 检查是否过期 + if (System.currentTimeMillis() > ticketInfo.expiresAt) { + return null + } + + return ticketInfo.username + } + + /** + * 清理过期票据 + */ + private fun cleanupExpiredTickets() { + val now = System.currentTimeMillis() + tickets.entries.removeIf { it.value.expiresAt < now } + } +} diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt index cda8d2b..2fd679e 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/BlockchainService.kt @@ -39,9 +39,16 @@ class BlockchainService( // USDC 合约地址(Polygon 主网,Polymarket 使用 Polygon) private val usdcContractAddress = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" - // Polymarket 代理工厂合约地址(Polygon 主网) + // Polymarket Safe 代理工厂合约地址(Polygon 主网,用于 MetaMask 用户) // 合约地址: 0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b - private val proxyFactoryContractAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" + private val safeProxyFactoryAddress = "0xaacFeEa03eb1561C4e67d661e40682Bd20E3541b" + + // Polymarket Magic 代理工厂合约地址(Polygon 主网,用于邮箱/OAuth 登录用户) + // 合约地址: 0xaB45c5A4B0c941a2F231C04C3f49182e1A254052 + private val magicProxyFactoryAddress = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052" + + // Magic Proxy 的 init code hash(用于 CREATE2 计算) + private val magicProxyInitCodeHash = "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b" // ConditionalTokens 合约地址(Polygon 主网) private val conditionalTokensAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" @@ -78,60 +85,162 @@ class BlockchainService( /** * 获取 Polymarket 代理钱包地址 - * 通过 RPC 调用代理工厂合约获取用户的代理钱包地址 + * 根据指定的钱包类型返回对应的代理地址 + * + * Polymarket 有两种代理钱包类型: + * 1. Magic Proxy(邮箱/OAuth 登录用户)- 使用 CREATE2 计算地址 + * 2. Safe Proxy(MetaMask 钱包用户)- 通过合约调用获取地址 + * + * @param walletAddress 用户的钱包地址(EOA) + * @param walletType 钱包类型:"magic"(默认)或 "safe" + * @return 代理钱包地址 + */ + suspend fun getProxyAddress(walletAddress: String, walletType: String = "magic"): Result { + return try { + when (walletType.lowercase()) { + "safe" -> { + // Safe Proxy(MetaMask 用户) + val safeProxyResult = getSafeProxyAddress(walletAddress) + if (safeProxyResult.isSuccess) { + val safeProxyAddress = safeProxyResult.getOrNull()!! + logger.debug("使用 Safe Proxy 地址: $safeProxyAddress") + Result.success(safeProxyAddress) + } else { + Result.failure(safeProxyResult.exceptionOrNull() ?: Exception("获取 Safe Proxy 地址失败")) + } + } + else -> { + // Magic Proxy(邮箱/OAuth 登录用户)- 默认 + val magicProxyAddress = calculateMagicProxyAddress(walletAddress) + logger.debug("使用 Magic Proxy 地址: $magicProxyAddress") + Result.success(magicProxyAddress) + } + } + } catch (e: Exception) { + logger.error("获取代理地址失败: ${e.message}", e) + Result.failure(e) + } + } + + /** + * 计算 Magic Proxy 地址(使用 CREATE2) + * 用于邮箱/OAuth 登录的用户 + * + * CREATE2 地址计算公式: + * address = keccak256(0xff ++ factory ++ salt ++ initCodeHash)[12:] + * salt = keccak256(eoaAddress) + * + * @param walletAddress 用户的钱包地址(EOA) + * @return Magic 代理钱包地址 + */ + fun calculateMagicProxyAddress(walletAddress: String): String { + // 计算 salt = keccak256(eoaAddress) + val eoaBytes = EthereumUtils.hexToBytes(walletAddress.lowercase()) + val salt = EthereumUtils.keccak256(eoaBytes) + + // 计算 CREATE2 地址 + // data = 0xff ++ factory ++ salt ++ initCodeHash + val prefix = byteArrayOf(0xff.toByte()) + val factoryBytes = EthereumUtils.hexToBytes(magicProxyFactoryAddress) + val initCodeHashBytes = EthereumUtils.hexToBytes(magicProxyInitCodeHash) + + val data = prefix + factoryBytes + salt + initCodeHashBytes + val hash = EthereumUtils.keccak256(data) + + // 取后 20 字节作为地址 + return "0x" + hash.copyOfRange(12, 32).joinToString("") { "%02x".format(it) } + } + + /** + * 获取 Safe Proxy 地址 + * 通过 RPC 调用 Safe 代理工厂合约获取用户的代理钱包地址 + * 用于 MetaMask 钱包用户 + * * @param walletAddress 用户的钱包地址 * @return 代理钱包地址 */ - suspend fun getProxyAddress(walletAddress: String): Result { + private suspend fun getSafeProxyAddress(walletAddress: String): Result { return try { val rpcApi = polygonRpcApi - + // 计算函数选择器 val functionSelector = EthereumUtils.getFunctionSelector(computeProxyAddressFunctionSignature) // 编码地址参数 val encodedAddress = EthereumUtils.encodeAddress(walletAddress) // 构建调用数据 val data = functionSelector + encodedAddress - + // 构建 JSON-RPC 请求 val rpcRequest = JsonRpcRequest( method = "eth_call", params = listOf( mapOf( - "to" to proxyFactoryContractAddress, + "to" to safeProxyFactoryAddress, "data" to data ), "latest" ) ) - + // 发送 RPC 请求 val response = rpcApi.call(rpcRequest) - + if (!response.isSuccessful || response.body() == null) { - throw Exception("RPC 请求失败: ${response.code()} ${response.message()}") + return Result.failure(Exception("RPC 请求失败: ${response.code()} ${response.message()}")) } - + val rpcResponse = response.body()!! - + // 检查错误 if (rpcResponse.error != null) { - throw Exception("RPC 错误: ${rpcResponse.error.message}") + return Result.failure(Exception("RPC 错误: ${rpcResponse.error.message}")) } - + // 使用 Gson 解析 result(JsonElement) - val hexResult = rpcResponse.result?.asString - ?: throw Exception("RPC 响应格式错误: result 为空") - + val hexResult = rpcResponse.result?.asString + ?: return Result.failure(Exception("RPC 响应格式错误: result 为空")) + // 解析代理地址 val proxyAddress = EthereumUtils.decodeAddress(hexResult) - + Result.success(proxyAddress) } catch (e: Exception) { - logger.error("获取代理地址失败: ${e.message}", e) Result.failure(e) } } + + /** + * 检查地址是否是合约 + * @param address 地址 + * @return 如果地址有代码(是合约)返回 true + */ + private suspend fun isContract(address: String): Boolean { + return try { + val rpcApi = polygonRpcApi + + val rpcRequest = JsonRpcRequest( + method = "eth_getCode", + params = listOf(address, "latest") + ) + + val response = rpcApi.call(rpcRequest) + if (!response.isSuccessful || response.body() == null) { + return false + } + + val rpcResponse = response.body()!! + if (rpcResponse.error != null) { + return false + } + + val code = rpcResponse.result?.asString ?: "0x" + // 如果代码不是 "0x" 或 "0x0",则是合约 + code != "0x" && code != "0x0" + } catch (e: Exception) { + logger.warn("检查合约地址失败: ${e.message}") + false + } + } /** * 查询账户 USDC 余额 diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketApiKeyService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketApiKeyService.kt index d8f0e95..37ff3ee 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketApiKeyService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/PolymarketApiKeyService.kt @@ -54,34 +54,36 @@ class PolymarketApiKeyService( try { // 先尝试获取现有的 API Key(derive) val deriveResult = deriveApiKey(privateKey, walletAddress, chainId) + val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}" if (deriveResult.isSuccess) { val creds = deriveResult.getOrNull() if (creds != null && isApiCreds(creds)) { - logger.info("成功获取现有 API Key: ${walletAddress}") + logger.debug("成功获取现有 API Key: $maskedAddress") return@runBlocking Result.success(creds) } } - + // 如果获取失败或返回无效,尝试创建新的 - logger.info("获取现有 API Key 失败,尝试创建新的: ${walletAddress}") + logger.debug("获取现有 API Key 失败,尝试创建新的: $maskedAddress") val createResult = createApiKey(privateKey, walletAddress, chainId) if (createResult.isSuccess) { val creds = createResult.getOrNull() if (creds != null && isApiCreds(creds)) { - logger.info("成功创建新 API Key: ${walletAddress}") + logger.debug("成功创建新 API Key: $maskedAddress") return@runBlocking Result.success(creds) } } - + // 两个都失败 val error = createResult.exceptionOrNull() ?: deriveResult.exceptionOrNull() val errorMsg = error?.message ?: "未知错误" - logger.error("获取和创建 API Key 都失败: ${walletAddress}", error) + logger.error("获取和创建 API Key 都失败: $maskedAddress", error) Result.failure( IllegalStateException("无法获取或创建 API Key: $errorMsg") ) } catch (e: Exception) { - logger.error("创建或获取 API Key 异常: ${walletAddress}", e) + val maskedAddress = "${walletAddress.take(6)}...${walletAddress.takeLast(4)}" + logger.error("创建或获取 API Key 异常: $maskedAddress", e) Result.failure(e) } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/RateLimitService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/RateLimitService.kt index ca0bf41..4687857 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/RateLimitService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/common/RateLimitService.kt @@ -3,50 +3,133 @@ package com.wrbug.polymarketbot.service.common import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference /** - * 频率限制服务(使用内存缓存,全局限制) + * 频率限制服务(使用内存缓存) */ @Service class RateLimitService { - + private val logger = LoggerFactory.getLogger(RateLimitService::class.java) - + + // 重置密码限速配置 @Value("\${rate-limit.reset-password.max-attempts:3}") - private var maxAttempts: Int = 3 - + private var resetPasswordMaxAttempts: Int = 3 + @Value("\${rate-limit.reset-password.window-seconds:60}") - private var windowSeconds: Long = 60 - + private var resetPasswordWindowSeconds: Long = 60 + + // 登录限速配置 + @Value("\${rate-limit.login.max-attempts:5}") + private var loginMaxAttempts: Int = 5 + + @Value("\${rate-limit.login.window-seconds:300}") + private var loginWindowSeconds: Long = 300 // 5分钟 + + @Value("\${rate-limit.login.lockout-seconds:900}") + private var loginLockoutSeconds: Long = 900 // 15分钟 + // 全局尝试记录列表(时间戳),所有请求共享 private val resetPasswordAttempts = AtomicReference>(mutableListOf()) - + + // 登录失败尝试记录(IP -> 时间戳列表) + private val loginFailedAttempts = ConcurrentHashMap>() + + // 登录锁定记录(IP -> 锁定结束时间) + private val loginLockouts = ConcurrentHashMap() + /** * 检查重置密码频率限制(全局限制,不按IP) * @return Result,如果超过限制则返回失败 */ fun checkResetPasswordRateLimit(): Result { val now = System.currentTimeMillis() - val windowStart = now - (windowSeconds * 1000) - + val windowStart = now - (resetPasswordWindowSeconds * 1000) + // 获取当前尝试记录列表 val attempts = resetPasswordAttempts.get() - + // 清理过期记录(超过时间窗口的记录) val validAttempts = attempts.filter { it >= windowStart }.toMutableList() - + // 检查是否超过限制 - if (validAttempts.size >= maxAttempts) { - logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$maxAttempts") - return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${maxAttempts}次,请稍后再试")) + if (validAttempts.size >= resetPasswordMaxAttempts) { + logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$resetPasswordMaxAttempts") + return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${resetPasswordMaxAttempts}次,请稍后再试")) } - + // 记录本次尝试 validAttempts.add(now) resetPasswordAttempts.set(validAttempts) - + return Result.success(Unit) } + + /** + * 检查登录频率限制(按IP限制) + * @param ipAddress 客户端IP地址 + * @return Result,如果被锁定或超过限制则返回失败 + */ + fun checkLoginRateLimit(ipAddress: String): Result { + val now = System.currentTimeMillis() + + // 检查是否被锁定 + val lockoutEndTime = loginLockouts[ipAddress] + if (lockoutEndTime != null) { + if (now < lockoutEndTime) { + val remainingSeconds = (lockoutEndTime - now) / 1000 + logger.warn("登录锁定中: ip=$ipAddress, remainingSeconds=$remainingSeconds") + return Result.failure(IllegalStateException("账户已被锁定,请${remainingSeconds}秒后再试")) + } else { + // 锁定已过期,清除锁定记录 + loginLockouts.remove(ipAddress) + loginFailedAttempts.remove(ipAddress) + } + } + + return Result.success(Unit) + } + + /** + * 记录登录失败尝试 + * @param ipAddress 客户端IP地址 + * @return 如果触发锁定返回锁定信息,否则返回 null + */ + fun recordLoginFailure(ipAddress: String): String? { + val now = System.currentTimeMillis() + val windowStart = now - (loginWindowSeconds * 1000) + + // 获取或创建该IP的尝试记录 + val attempts = loginFailedAttempts.computeIfAbsent(ipAddress) { mutableListOf() } + + // 清理过期记录并添加新记录 + synchronized(attempts) { + attempts.removeIf { it < windowStart } + attempts.add(now) + + // 检查是否需要锁定 + if (attempts.size >= loginMaxAttempts) { + val lockoutEndTime = now + (loginLockoutSeconds * 1000) + loginLockouts[ipAddress] = lockoutEndTime + logger.warn("登录锁定触发: ip=$ipAddress, attempts=${attempts.size}, lockoutSeconds=$loginLockoutSeconds") + return "登录失败次数过多,账户已被锁定${loginLockoutSeconds / 60}分钟" + } + } + + val remainingAttempts = loginMaxAttempts - attempts.size + logger.warn("登录失败: ip=$ipAddress, attempts=${attempts.size}/$loginMaxAttempts, remainingAttempts=$remainingAttempts") + return null + } + + /** + * 登录成功时清除失败记录 + * @param ipAddress 客户端IP地址 + */ + fun clearLoginFailures(ipAddress: String) { + loginFailedAttempts.remove(ipAddress) + loginLockouts.remove(ipAddress) + } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt index 9705264..bff4c60 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/copytrading/orders/OrderSigningService.kt @@ -172,25 +172,15 @@ class OrderSigningService { // 5. 确保 maker 地址也是小写格式 val makerAddressLower = makerAddress.lowercase() - // 打印签名前的订单参数 - logger.info("========== 订单签名前参数 ==========") - logger.info("订单方向: $side") - logger.info("价格: $price") - logger.info("数量: $size") - logger.info("Token ID: $tokenId") - logger.info("Maker 地址: $makerAddressLower") - logger.info("Signer 地址: $signerAddress") - logger.info("Taker 地址: $taker") - logger.info("Maker Amount (wei): ${amounts.makerAmount}") - logger.info("Taker Amount (wei): ${amounts.takerAmount}") - logger.info("Salt: $salt") - logger.info("Expiration: $expiration") - logger.info("Nonce: $nonce") - logger.info("Fee Rate BPS: $feeRateBps") - logger.info("Signature Type: $signatureType") - logger.info("Exchange Contract: $EXCHANGE_CONTRACT") - logger.info("Chain ID: $CHAIN_ID") - logger.info("====================================") + // 打印签名前的订单参数(DEBUG 级别,避免敏感信息泄露) + logger.debug("========== 订单签名前参数 ==========") + logger.debug("订单方向: $side, 价格: $price, 数量: $size") + logger.debug("Token ID: $tokenId") + logger.debug("Maker: ${makerAddressLower.take(10)}...${makerAddressLower.takeLast(6)}") + logger.debug("Signer: ${signerAddress.take(10)}...${signerAddress.takeLast(6)}") + logger.debug("Amounts - Maker: ${amounts.makerAmount}, Taker: ${amounts.takerAmount}") + logger.debug("Salt: $salt, Expiration: $expiration, Nonce: $nonce, FeeRateBPS: $feeRateBps") + logger.debug("Signature Type: $signatureType, Chain ID: $CHAIN_ID") // 6. 构建订单数据并签名 val signature = signOrder( diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt index 89c2f0a..898a9cd 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/RelayClientService.kt @@ -333,15 +333,10 @@ class RelayClientService( // 打包签名(参考 builder-relayer-client/src/utils/index.ts 的 splitAndPackSig) val packedSignature = splitAndPackSig(safeSignature) - // 调试日志 + // 调试日志(地址已遮蔽) logger.debug("=== Builder Relayer 签名调试 ===") - logger.debug("Safe Address: $proxyAddress") - logger.debug("From Address: $fromAddress") - logger.debug("To: ${safeTx.to}") - logger.debug("Data: $redeemCallData") - logger.debug("Nonce: $proxyNonce") - logger.debug("Packed Signature: $packedSignature") - logger.debug("Signature Length: ${packedSignature.length} (expected: 132 with 0x)") + logger.debug("Safe: ${proxyAddress.take(10)}..., From: ${fromAddress.take(10)}..., Nonce: $proxyNonce") + logger.debug("Signature Length: ${packedSignature.length}") // 构建 TransactionRequest(参考 builder-relayer-client/src/builder/safe.ts) // 注意:根据 TypeScript 实现,data 和 signature 都应该带 0x 前缀 @@ -364,13 +359,7 @@ class RelayClientService( metadata = "Redeem positions via Builder Relayer" ) - logger.debug("Request Type: ${request.type}") - logger.debug("Request From: ${request.from}") - logger.debug("Request To: ${request.to}") - logger.debug("Request ProxyWallet: ${request.proxyWallet}") - logger.debug("Request Data Length: ${request.data.length}") - logger.debug("Request Signature Length: ${request.signature.length}") - logger.debug("Request Nonce: ${request.nonce}") + logger.debug("Request: type=${request.type}, dataLen=${request.data.length}, sigLen=${request.signature.length}, nonce=${request.nonce}") // 调用 Builder Relayer API(认证头通过拦截器添加) val response = relayerApi.submitTransaction(request) diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/SystemConfigService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/SystemConfigService.kt index db20e6c..819c1e9 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/SystemConfigService.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/system/SystemConfigService.kt @@ -25,6 +25,19 @@ class SystemConfigService( const val CONFIG_KEY_BUILDER_SECRET = "builder.secret" const val CONFIG_KEY_BUILDER_PASSPHRASE = "builder.passphrase" const val CONFIG_KEY_AUTO_REDEEM = "auto_redeem" + + /** + * 遮蔽敏感信息,仅显示前4位和后4位 + * 例如:abcd1234...wxyz5678 + */ + fun maskSensitiveValue(value: String?): String? { + if (value == null) return null + return when { + value.length <= 8 -> "****" // 太短则完全遮蔽 + value.length <= 16 -> "${value.take(2)}...${value.takeLast(2)}" + else -> "${value.take(4)}...${value.takeLast(4)}" + } + } } /** @@ -36,26 +49,26 @@ class SystemConfigService( val builderPassphrase = getConfigValue(CONFIG_KEY_BUILDER_PASSPHRASE) val autoRedeem = isAutoRedeemEnabled() - // 获取完整的 API Key(用于前端展示) - val builderApiKeyDisplay = builderApiKey?.let { + // 获取遮蔽后的显示值(仅显示部分字符,用于前端确认配置) + val builderApiKeyDisplay = builderApiKey?.let { try { - cryptoUtils.decrypt(it) + maskSensitiveValue(cryptoUtils.decrypt(it)) } catch (e: Exception) { null } } - + val builderSecretDisplay = builderSecret?.let { try { - cryptoUtils.decrypt(it) + maskSensitiveValue(cryptoUtils.decrypt(it)) } catch (e: Exception) { null } } - + val builderPassphraseDisplay = builderPassphrase?.let { try { - cryptoUtils.decrypt(it) + maskSensitiveValue(cryptoUtils.decrypt(it)) } catch (e: Exception) { null } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt index fff918d..f4fc82d 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/EthereumUtils.kt @@ -19,7 +19,7 @@ object EthereumUtils { * @return 函数选择器,例如 "0x12345678" */ fun getFunctionSelector(functionSignature: String): String { - val hash = keccak256(functionSignature.toByteArray()) + val hash = keccak256Hex(functionSignature.toByteArray()) return "0x" + hash.substring(0, 8) } @@ -138,16 +138,48 @@ object EthereumUtils { return Pair(payoutDenominator, payouts) } + /** + * 将十六进制字符串转换为字节数组 + * @param hex 十六进制字符串(带或不带 0x 前缀) + * @return 字节数组 + */ + fun hexToBytes(hex: String): ByteArray { + val cleanHex = hex.removePrefix("0x") + return ByteArray(cleanHex.length / 2) { i -> + cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } + + /** + * 将字节数组转换为十六进制字符串 + * @param bytes 字节数组 + * @return 十六进制字符串(带 0x 前缀) + */ + fun bytesToHex(bytes: ByteArray): String { + return "0x" + bytes.joinToString("") { "%02x".format(it) } + } + /** * 计算 Keccak-256 哈希(Ethereum 标准) * 使用 BouncyCastle 库实现真正的 Keccak-256 + * @param data 输入数据 + * @return 32 字节的哈希值 */ - private fun keccak256(data: ByteArray): String { + fun keccak256(data: ByteArray): ByteArray { val digest = KeccakDigest(256) digest.update(data, 0, data.size) val hash = ByteArray(digest.digestSize) digest.doFinal(hash, 0) - return hash.joinToString("") { "%02x".format(it) } + return hash + } + + /** + * 计算 Keccak-256 哈希并返回十六进制字符串 + * @param data 输入数据 + * @return 十六进制哈希字符串 + */ + fun keccak256Hex(data: ByteArray): String { + return keccak256(data).joinToString("") { "%02x".format(it) } } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a775c71..544f8c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,7 @@ "antd": "^5.12.0", "antd-mobile": "^5.34.0", "axios": "^1.6.2", - "ethers": "^6.9.0", + "ethers": "^6.16.0", "i18next": "^25.7.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -158,6 +158,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1676,6 +1677,7 @@ "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1741,6 +1743,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -1937,6 +1940,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2255,6 +2259,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -2466,7 +2471,8 @@ "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -2687,6 +2693,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2877,9 +2884,9 @@ } }, "node_modules/ethers": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz", - "integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", "funding": [ { "type": "individual", @@ -2890,6 +2897,7 @@ "url": "https://www.buymeacoffee.com/ricmoo" } ], + "license": "MIT", "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", @@ -3364,6 +3372,7 @@ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" } ], + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -5432,6 +5441,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5443,6 +5453,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6010,6 +6021,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6182,6 +6194,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/package.json b/frontend/package.json index 088749d..acd692e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,7 @@ "antd": "^5.12.0", "antd-mobile": "^5.34.0", "axios": "^1.6.2", - "ethers": "^6.9.0", + "ethers": "^6.16.0", "i18next": "^25.7.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/components/AccountImportForm.tsx b/frontend/src/components/AccountImportForm.tsx index 0b2eac1..a541128 100644 --- a/frontend/src/components/AccountImportForm.tsx +++ b/frontend/src/components/AccountImportForm.tsx @@ -1,18 +1,20 @@ import { useState } from 'react' -import { Form, Input, Button, Radio, Space, Alert } from 'antd' +import { Form, Input, Button, Radio, Space, Alert, Tooltip } from 'antd' +import { QuestionCircleOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' import { useAccountStore } from '../store/accountStore' -import { - getAddressFromPrivateKey, +import { + getAddressFromPrivateKey, getAddressFromMnemonic, getPrivateKeyFromMnemonic, - isValidWalletAddress, + isValidWalletAddress, isValidPrivateKey, isValidMnemonic } from '../utils' import { useMediaQuery } from 'react-responsive' type ImportType = 'privateKey' | 'mnemonic' +type WalletType = 'magic' | 'safe' interface AccountImportFormProps { form: any @@ -33,6 +35,7 @@ const AccountImportForm: React.FC = ({ const isMobile = useMediaQuery({ maxWidth: 768 }) const { importAccount, loading } = useAccountStore() const [importType, setImportType] = useState('privateKey') + const [walletType, setWalletType] = useState('magic') const [derivedAddress, setDerivedAddress] = useState('') const [addressError, setAddressError] = useState('') @@ -141,7 +144,8 @@ const AccountImportForm: React.FC = ({ await importAccount({ privateKey: privateKey, walletAddress: walletAddress, - accountName: values.accountName + accountName: values.accountName, + walletType: walletType }) // 等待store更新 @@ -189,8 +193,8 @@ const AccountImportForm: React.FC = ({ size={isMobile ? 'middle' : 'large'} > - { setImportType(e.target.value) setDerivedAddress('') @@ -202,6 +206,29 @@ const AccountImportForm: React.FC = ({ {t('accountImport.mnemonic')} + + + {t('accountImport.walletType')}{' '} + + + + + } + > + setWalletType(e.target.value)} + > + + {t('accountImport.walletTypeMagic')} + + + {t('accountImport.walletTypeSafe')} + + + {importType === 'privateKey' ? ( <> diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index d78f1b9..2fae513 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -199,7 +199,11 @@ "importFailed": "Failed to import account", "derivedAddress": "Derived Address", "addressError": "Cannot derive address from private key", - "addressErrorMnemonic": "Cannot derive address from mnemonic" + "addressErrorMnemonic": "Cannot derive address from mnemonic", + "walletType": "Wallet Type", + "walletTypeHelp": "Magic: Polymarket accounts logged in via email or social accounts (Google, Twitter, etc.); MetaMask: Polymarket accounts connected via browser wallets like MetaMask", + "walletTypeMagic": "Magic (Email/Social Login)", + "walletTypeSafe": "MetaMask (Browser Wallet)" }, "leader": { "title": "Leader Management", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 3be328a..cb706aa 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -199,7 +199,11 @@ "importFailed": "导入账户失败", "derivedAddress": "推导地址", "addressError": "无法从私钥推导地址", - "addressErrorMnemonic": "无法从助记词推导地址" + "addressErrorMnemonic": "无法从助记词推导地址", + "walletType": "钱包类型", + "walletTypeHelp": "Magic:通过邮箱或社交账号(如 Google、Twitter)登录的 Polymarket 账户;MetaMask:使用 MetaMask 等浏览器钱包连接的 Polymarket 账户", + "walletTypeMagic": "Magic(邮箱/社交账号登录)", + "walletTypeSafe": "MetaMask(浏览器钱包)" }, "leader": { "title": "Leader 管理", diff --git a/frontend/src/locales/zh-TW/common.json b/frontend/src/locales/zh-TW/common.json index 5326c37..756df0b 100644 --- a/frontend/src/locales/zh-TW/common.json +++ b/frontend/src/locales/zh-TW/common.json @@ -199,7 +199,11 @@ "importFailed": "導入賬戶失敗", "derivedAddress": "推導地址", "addressError": "無法從私鑰推導地址", - "addressErrorMnemonic": "無法從助記詞推導地址" + "addressErrorMnemonic": "無法從助記詞推導地址", + "walletType": "錢包類型", + "walletTypeHelp": "Magic:透過郵箱或社群帳號(如 Google、Twitter)登入的 Polymarket 帳戶;MetaMask:使用 MetaMask 等瀏覽器錢包連接的 Polymarket 帳戶", + "walletTypeMagic": "Magic(郵箱/社群帳號登入)", + "walletTypeSafe": "MetaMask(瀏覽器錢包)" }, "leader": { "title": "Leader 管理", diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 681a65c..69010cb 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -181,18 +181,25 @@ export const apiService = { */ login: (data: { username: string; password: string }) => apiClient.post>('/auth/login', data), - + /** * 重置密码 */ resetPassword: (data: { resetKey: string; username: string; newPassword: string }) => apiClient.post>('/auth/reset-password', data), - + /** * 检查是否首次使用 */ checkFirstUse: () => - apiClient.post>('/auth/check-first-use', {}) + apiClient.post>('/auth/check-first-use', {}), + + /** + * 获取 WebSocket 连接票据 + * 返回一个短期有效(30秒)的一次性票据 + */ + getWebSocketTicket: () => + apiClient.post>('/auth/ws-ticket', {}) }, /** diff --git a/frontend/src/services/websocket.ts b/frontend/src/services/websocket.ts index 2af25d4..5ca7f26 100644 --- a/frontend/src/services/websocket.ts +++ b/frontend/src/services/websocket.ts @@ -51,30 +51,33 @@ class WebSocketManager { /** * 连接 WebSocket(全局共享连接) + * 使用短期票据认证,避免在 URL 中暴露 JWT */ - connect(): void { + async connect(): Promise { // 检查是否有token,未登录不允许连接 const token = this.getToken() if (!token) { console.log('[WebSocket] 未登录,不建立连接') return } - + // 如果已经连接或正在连接,直接返回 if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) { return } - + // 如果正在卸载,不允许连接 if (this.isUnmounting) { return } - + this.isConnecting = true - const wsUrl = this.getWebSocketUrl() - console.log('[WebSocket] 正在连接:', wsUrl) - + try { + // 获取短期票据 + const wsUrl = await this.getWebSocketUrl() + console.log('[WebSocket] 正在连接...') + // 如果已经有连接(但状态不是 OPEN),先关闭 if (this.ws) { try { @@ -84,10 +87,10 @@ class WebSocketManager { } this.ws = null } - + const ws = new WebSocket(wsUrl) this.ws = ws - + ws.onopen = () => { console.log('[WebSocket] 连接成功') this.isConnecting = false @@ -95,17 +98,17 @@ class WebSocketManager { this.startPing() this.resubscribeAll() // 重新订阅所有频道 } - + ws.onmessage = (event) => { this.handleMessage(event.data) } - + ws.onerror = (error) => { console.error('[WebSocket] 连接错误:', error) this.isConnecting = false this.notifyConnectionStatus(false) } - + ws.onclose = () => { console.log('[WebSocket] 连接关闭') this.isConnecting = false @@ -324,14 +327,14 @@ class WebSocketManager { } /** - * 获取 WebSocket URL(带token认证) + * 获取 WebSocket URL(使用短期票据认证) * 默认使用相对路径 /ws(通过反向代理转发) * 如果设置了 VITE_WS_URL 环境变量,则使用完整 URL(用于跨域场景) */ - private getWebSocketUrl(): string { + private async getWebSocketUrl(): Promise { const envWsUrl = import.meta.env.VITE_WS_URL let wsBaseUrl: string - + if (envWsUrl) { // 如果设置了环境变量,使用完整 URL(支持跨域) wsBaseUrl = envWsUrl @@ -341,10 +344,22 @@ class WebSocketManager { const host = window.location.host wsBaseUrl = `${protocol}//${host}` } - + + // 获取短期票据(避免在 URL 中暴露 JWT) + // 使用动态导入避免循环依赖 + try { + const { apiService } = await import('./api') + const response = await apiService.auth.getWebSocketTicket() + if (response.data.code === 0 && response.data.data?.ticket) { + return `${wsBaseUrl}/ws?ticket=${encodeURIComponent(response.data.data.ticket)}` + } + } catch (error) { + console.warn('[WebSocket] 获取票据失败,尝试使用 token 认证:', error) + } + + // 兼容旧方式:如果获取票据失败,回退到使用 token(不推荐) const token = this.getToken() if (token) { - // 通过查询参数传递token return `${wsBaseUrl}/ws?token=${encodeURIComponent(token)}` } return `${wsBaseUrl}/ws`