feat: 添加JWT登录鉴权和用户管理功能

- 后端功能:
  - 实现JWT登录鉴权,token有效期7天,超过1天自动刷新
  - 添加用户管理功能,支持创建、删除、修改密码
  - 首次创建的用户为默认账户,拥有管理权限
  - 实现密码重置功能,支持重置密钥和频率限制(1分钟最多3次)
  - 所有API接口需要JWT鉴权
  - WebSocket连接需要JWT鉴权,绑定用户身份

- 前端功能:
  - 添加登录页面和密码重置页面
  - 添加用户管理页面,默认账户可管理所有用户,普通用户只能查看和修改自己
  - 添加退出登录功能,带二次确认
  - 未登录时不建立WebSocket连接
  - API请求自动携带JWT token,认证失败自动跳转登录页

- 安全特性:
  - 密码使用BCrypt加密存储
  - 重置密码错误信息统一处理,避免信息泄露
  - 用户操作严格绑定JWT,防止数据篡改和越权
This commit is contained in:
WrBug
2025-12-03 00:24:46 +08:00
parent f1c9b34488
commit dd553ae160
30 changed files with 2402 additions and 47 deletions
@@ -0,0 +1,104 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.dto.ApiResponse
import com.wrbug.polymarketbot.util.JwtUtils
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
/**
* JWT认证拦截器
*/
@Component
class JwtAuthenticationInterceptor(
private val jwtUtils: JwtUtils
) : HandlerInterceptor {
private val logger = LoggerFactory.getLogger(JwtAuthenticationInterceptor::class.java)
private val objectMapper = ObjectMapper()
// 不需要鉴权的路径
private val excludePaths = setOf(
"/api/auth/login",
"/api/auth/reset-password",
"/api/auth/check-first-use"
)
override fun preHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any
): Boolean {
val path = request.requestURI
val method = request.method
// 只拦截POST请求
if (method != "POST") {
return true
}
// 排除不需要鉴权的路径
if (excludePaths.contains(path)) {
return true
}
// 只拦截 /api/** 路径
if (!path.startsWith("/api/")) {
return true
}
// 从请求头获取token
val authHeader = request.getHeader("Authorization")
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
sendAuthError(response, "缺少认证令牌")
return false
}
val token = authHeader.substring(7) // 移除 "Bearer " 前缀
// 验证token
if (!jwtUtils.validateToken(token)) {
logger.warn("Token验证失败: 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")
}
}
// 将用户名存入Request属性,供后续使用
val username = jwtUtils.getUsernameFromToken(token)
if (username != null) {
request.setAttribute("username", username)
}
return true
}
/**
* 发送认证错误响应
*/
private fun sendAuthError(response: HttpServletResponse, message: String) {
response.status = HttpServletResponse.SC_OK
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"
val apiResponse: ApiResponse<Unit> = ApiResponse.authError(message)
val json = objectMapper.writeValueAsString(apiResponse)
response.writer.write(json)
response.writer.flush()
}
}
@@ -0,0 +1,21 @@
package com.wrbug.polymarketbot.config
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
/**
* Web MVC 配置
* 注册JWT认证拦截器
*/
@Configuration
class WebMvcConfig(
private val jwtAuthenticationInterceptor: JwtAuthenticationInterceptor
) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
}
}
@@ -0,0 +1,93 @@
package com.wrbug.polymarketbot.config
import com.wrbug.polymarketbot.util.JwtUtils
import org.slf4j.LoggerFactory
import org.springframework.http.server.ServerHttpRequest
import org.springframework.http.server.ServerHttpResponse
import org.springframework.stereotype.Component
import org.springframework.web.socket.WebSocketHandler
import org.springframework.web.socket.server.HandshakeInterceptor
/**
* WebSocket 握手拦截器
* 用于验证 JWT token
*/
@Component
class WebSocketAuthInterceptor(
private val jwtUtils: JwtUtils
) : HandshakeInterceptor {
private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java)
override fun beforeHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>
): Boolean {
// 从查询参数或请求头获取 token
val token = getTokenFromRequest(request)
if (token == null) {
logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 验证 token
if (!jwtUtils.validateToken(token)) {
logger.warn("WebSocket 连接 token 验证失败: ${request.uri}")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
// 获取用户名并存入 attributes,供后续使用
val username = jwtUtils.getUsernameFromToken(token)
if (username != null) {
attributes["username"] = username
logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}")
} else {
logger.warn("WebSocket 连接无法获取用户名: ${request.uri}")
response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED)
return false
}
return true
}
override fun afterHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
exception: Exception?
) {
// 握手后处理(如果需要)
}
/**
* 从请求中获取 token
* 支持从查询参数 token 或请求头 Authorization 获取
*/
private fun getTokenFromRequest(request: ServerHttpRequest): String? {
// 优先从查询参数获取
val queryParams = request.uri.query
if (queryParams != null) {
val params = queryParams.split("&")
for (param in params) {
val parts = param.split("=", limit = 2)
if (parts.size == 2 && parts[0] == "token") {
return parts[1]
}
}
}
// 从请求头获取
val authHeader = request.headers.getFirst("Authorization")
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7)
}
return null
}
}
@@ -15,17 +15,21 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
@EnableWebSocket
class WebSocketConfig(
private val polymarketWebSocketHandler: PolymarketWebSocketHandler,
private val unifiedWebSocketHandler: UnifiedWebSocketHandler
private val unifiedWebSocketHandler: UnifiedWebSocketHandler,
private val webSocketAuthInterceptor: WebSocketAuthInterceptor
) : WebSocketConfigurer {
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
// Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流)
// 注意:此端点不需要鉴权,因为它只是转发外部数据
registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket")
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
// 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分)
// 支持的频道:position(仓位推送)、order(订单推送,待实现)等
// 需要 JWT 鉴权
registry.addHandler(unifiedWebSocketHandler, "/ws")
.addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器
.setAllowedOrigins("*") // 生产环境应该配置具体的域名
}
}
@@ -0,0 +1,134 @@
package com.wrbug.polymarketbot.controller
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.AuthService
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 认证控制器
*/
@RestController
@RequestMapping("/api/auth")
class AuthController(
private val authService: AuthService
) {
private val logger = LoggerFactory.getLogger(AuthController::class.java)
/**
* 登录接口
*/
@PostMapping("/login")
fun login(@RequestBody request: LoginRequest): ResponseEntity<ApiResponse<LoginResponse>> {
return try {
if (request.username.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("用户名不能为空"))
}
if (request.password.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("密码不能为空"))
}
val result = authService.login(request.username, request.password)
result.fold(
onSuccess = { loginResponse ->
ResponseEntity.ok(ApiResponse.success(loginResponse))
},
onFailure = { e ->
logger.error("登录失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> {
if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) {
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.code, e.message ?: "用户名或密码错误"))
} else {
ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
}
}
else -> ResponseEntity.ok(ApiResponse.serverError("登录失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("登录异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("登录失败: ${e.message}"))
}
}
/**
* 重置密码接口
*/
@PostMapping("/reset-password")
fun resetPassword(
@RequestBody request: ResetPasswordRequest,
httpRequest: HttpServletRequest
): ResponseEntity<ApiResponse<Unit>> {
return try {
if (request.resetKey.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("重置密钥不能为空"))
}
if (request.username.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("用户名不能为空"))
}
if (request.newPassword.isBlank()) {
return ResponseEntity.ok(ApiResponse.paramError("新密码不能为空"))
}
val result = authService.resetPassword(
resetKey = request.resetKey,
username = request.username,
newPassword = request.newPassword,
request = httpRequest
)
result.fold(
onSuccess = {
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("重置密码失败: ${e.message}", e)
// 统一返回"重置失败",不暴露具体错误原因(安全考虑)
// 但密码强度错误可以提示,因为这是输入格式问题
when (e) {
is IllegalArgumentException -> {
if (e.message == ErrorCode.AUTH_PASSWORD_WEAK.message) {
// 密码强度错误可以提示
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PASSWORD_WEAK.code, e.message ?: "密码长度不符合要求"))
} else {
// 其他错误统一返回"重置失败"
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "重置失败"))
}
}
is IllegalStateException -> {
// 频率限制等错误统一返回"重置失败"
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "重置失败"))
}
else -> {
ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "重置失败"))
}
}
}
)
} catch (e: Exception) {
logger.error("重置密码异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("重置密码失败: ${e.message}"))
}
}
/**
* 检查是否首次使用接口
*/
@PostMapping("/check-first-use")
fun checkFirstUse(): ResponseEntity<ApiResponse<CheckFirstUseResponse>> {
return try {
val isFirstUse = authService.isFirstUse()
ResponseEntity.ok(ApiResponse.success(CheckFirstUseResponse(isFirstUse = isFirstUse)))
} catch (e: Exception) {
logger.error("检查首次使用异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("检查首次使用失败: ${e.message}"))
}
}
}
@@ -0,0 +1,205 @@
package com.wrbug.polymarketbot.controller
import com.wrbug.polymarketbot.dto.*
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.service.UserService
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
/**
* 用户管理控制器
*/
@RestController
@RequestMapping("/api/users")
class UserController(
private val userService: UserService
) {
private val logger = LoggerFactory.getLogger(UserController::class.java)
/**
* 获取当前用户名(从Request属性中获取)
*/
private fun getCurrentUsername(request: HttpServletRequest): String? {
return request.getAttribute("username") as? String
}
/**
* 验证是否为默认账户
*/
private fun checkDefaultUser(request: HttpServletRequest): String? {
val username = getCurrentUsername(request)
if (username == null) {
return "未获取到用户信息"
}
if (!userService.isDefaultUser(username)) {
return "只有默认账户可以执行此操作"
}
return null
}
/**
* 获取用户列表
* 所有用户都可以访问,但非默认账户只能看到自己的信息
*/
@PostMapping("/list")
fun getUserList(request: HttpServletRequest): ResponseEntity<ApiResponse<List<UserDto>>> {
return try {
val currentUsername = getCurrentUsername(request)
if (currentUsername == null) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "未获取到用户信息"))
}
val users = userService.getUserList(currentUsername)
ResponseEntity.ok(ApiResponse.success(users))
} catch (e: Exception) {
logger.error("获取用户列表异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("获取用户列表失败: ${e.message}"))
}
}
/**
* 创建用户
*/
@PostMapping("/create")
fun createUser(
@RequestBody requestBody: UserCreateRequest,
httpRequest: HttpServletRequest
): ResponseEntity<ApiResponse<UserDto>> {
return try {
val error = checkDefaultUser(httpRequest)
if (error != null) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, error))
}
val currentUsername = getCurrentUsername(httpRequest)!!
val result = userService.createUser(requestBody, currentUsername)
result.fold(
onSuccess = { user ->
ResponseEntity.ok(ApiResponse.success(user))
},
onFailure = { e ->
logger.error("创建用户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, e.message ?: "权限不足"))
else -> ResponseEntity.ok(ApiResponse.serverError("创建用户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("创建用户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("创建用户失败: ${e.message}"))
}
}
/**
* 更新用户密码
*/
@PostMapping("/update-password")
fun updateUserPassword(
@RequestBody requestBody: UserUpdatePasswordRequest,
httpRequest: HttpServletRequest
): ResponseEntity<ApiResponse<Unit>> {
return try {
val error = checkDefaultUser(httpRequest)
if (error != null) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, error))
}
val currentUsername = getCurrentUsername(httpRequest)!!
val result = userService.updateUserPassword(requestBody, currentUsername)
result.fold(
onSuccess = {
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("更新用户密码失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, e.message ?: "权限不足"))
else -> ResponseEntity.ok(ApiResponse.serverError("更新用户密码失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("更新用户密码异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("更新用户密码失败: ${e.message}"))
}
}
/**
* 用户修改自己的密码
*/
@PostMapping("/update-own-password")
fun updateOwnPassword(
@RequestBody requestBody: UserUpdateOwnPasswordRequest,
httpRequest: HttpServletRequest
): ResponseEntity<ApiResponse<Unit>> {
return try {
val currentUsername = getCurrentUsername(httpRequest)
if (currentUsername == null) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "未获取到用户信息"))
}
val result = userService.updateOwnPassword(requestBody.newPassword, currentUsername)
result.fold(
onSuccess = {
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("修改自己密码失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
else -> ResponseEntity.ok(ApiResponse.serverError("修改密码失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("修改自己密码异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("修改密码失败: ${e.message}"))
}
}
/**
* 删除用户
*/
@PostMapping("/delete")
fun deleteUser(
@RequestBody requestBody: UserDeleteRequest,
httpRequest: HttpServletRequest
): ResponseEntity<ApiResponse<Unit>> {
return try {
val error = checkDefaultUser(httpRequest)
if (error != null) {
return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, error))
}
val currentUsername = getCurrentUsername(httpRequest)!!
val result = userService.deleteUser(requestBody.userId, currentUsername)
result.fold(
onSuccess = {
ResponseEntity.ok(ApiResponse.success(Unit))
},
onFailure = { e ->
logger.error("删除用户失败: ${e.message}", e)
when (e) {
is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误"))
is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, e.message ?: "权限不足"))
else -> ResponseEntity.ok(ApiResponse.serverError("删除用户失败: ${e.message}"))
}
}
)
} catch (e: Exception) {
logger.error("删除用户异常: ${e.message}", e)
ResponseEntity.ok(ApiResponse.serverError("删除用户失败: ${e.message}"))
}
}
}
@@ -0,0 +1,19 @@
package com.wrbug.polymarketbot.dto
/**
* 登录请求
*/
data class LoginRequest(
val username: String,
val password: String
)
/**
* 重置密码请求
*/
data class ResetPasswordRequest(
val resetKey: String,
val username: String,
val newPassword: String
)
@@ -0,0 +1,16 @@
package com.wrbug.polymarketbot.dto
/**
* 登录响应
*/
data class LoginResponse(
val token: String
)
/**
* 检查首次使用响应
*/
data class CheckFirstUseResponse(
val isFirstUse: Boolean
)
@@ -0,0 +1,13 @@
package com.wrbug.polymarketbot.dto
/**
* 用户DTO
*/
data class UserDto(
val id: Long,
val username: String,
val isDefault: Boolean,
val createdAt: Long,
val updatedAt: Long
)
@@ -0,0 +1,32 @@
package com.wrbug.polymarketbot.dto
/**
* 创建用户请求
*/
data class UserCreateRequest(
val username: String,
val password: String
)
/**
* 更新用户密码请求(管理员修改其他用户密码)
*/
data class UserUpdatePasswordRequest(
val userId: Long,
val newPassword: String
)
/**
* 用户修改自己密码请求
*/
data class UserUpdateOwnPasswordRequest(
val newPassword: String
)
/**
* 删除用户请求
*/
data class UserDeleteRequest(
val userId: Long
)
@@ -0,0 +1,30 @@
package com.wrbug.polymarketbot.entity
import jakarta.persistence.*
/**
* 用户实体(用于JWT登录鉴权)
*/
@Entity
@Table(name = "users")
data class User(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null,
@Column(name = "username", unique = true, nullable = false, length = 50)
val username: String,
@Column(name = "password", nullable = false, length = 255)
val password: String, // BCrypt加密后的密码
@Column(name = "is_default", nullable = false)
val isDefault: Boolean = false, // 是否默认账户(首次创建的用户)
@Column(name = "created_at", nullable = false)
val createdAt: Long = System.currentTimeMillis(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Long = System.currentTimeMillis()
)
@@ -75,6 +75,11 @@ enum class ErrorCode(
AUTH_API_SECRET_INVALID(2006, "API Secret 无效"),
AUTH_API_PASSPHRASE_INVALID(2007, "API Passphrase 无效"),
AUTH_API_CREDENTIALS_MISSING(2008, "API 凭证未配置"),
AUTH_USERNAME_OR_PASSWORD_ERROR(2009, "用户名或密码错误"),
AUTH_RESET_KEY_INVALID(2010, "重置密钥错误"),
AUTH_RESET_PASSWORD_RATE_LIMIT(2011, "频率限制:1分钟内最多尝试3次,请稍后再试"),
AUTH_USER_NOT_FOUND(2012, "用户不存在"),
AUTH_PASSWORD_WEAK(2013, "密码长度不符合要求,至少6位"),
// ==================== 资源不存在 (3001-3999) ====================
NOT_FOUND(3001, "资源不存在"),
@@ -0,0 +1,33 @@
package com.wrbug.polymarketbot.repository
import com.wrbug.polymarketbot.entity.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
/**
* 用户 Repository
*/
@Repository
interface UserRepository : JpaRepository<User, Long> {
/**
* 根据用户名查找用户
*/
fun findByUsername(username: String): User?
/**
* 检查用户名是否存在
*/
fun existsByUsername(username: String): Boolean
/**
* 查找默认账户
*/
fun findByIsDefaultTrue(): User?
/**
* 查找所有用户,按创建时间排序
*/
fun findAllByOrderByCreatedAtAsc(): List<User>
}
@@ -0,0 +1,196 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.CheckFirstUseResponse
import com.wrbug.polymarketbot.dto.LoginResponse
import com.wrbug.polymarketbot.entity.User
import com.wrbug.polymarketbot.enums.ErrorCode
import com.wrbug.polymarketbot.repository.UserRepository
import com.wrbug.polymarketbot.util.JwtUtils
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 认证服务
*/
@Service
class AuthService(
private val userRepository: UserRepository,
private val jwtUtils: JwtUtils,
private val rateLimitService: RateLimitService
) {
private val logger = LoggerFactory.getLogger(AuthService::class.java)
private val passwordEncoder = BCryptPasswordEncoder()
@Value("\${admin.reset-password.key}")
private lateinit var resetPasswordKey: String
/**
* 登录
*/
fun login(username: String, password: String): Result<LoginResponse> {
return try {
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")
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message))
}
// 生成JWT token
val token = jwtUtils.generateToken(username)
logger.info("用户登录成功:username=$username")
Result.success(LoginResponse(token = token))
} catch (e: Exception) {
logger.error("登录异常:username=$username", e)
Result.failure(e)
}
}
/**
* 重置密码
*/
@Transactional
fun resetPassword(
resetKey: String,
username: String,
newPassword: String,
request: HttpServletRequest
): Result<Unit> {
return try {
// 先检查频率限制(全局限制,不按IP)
rateLimitService.checkResetPasswordRateLimit().fold(
onSuccess = { },
onFailure = { e ->
logger.warn("重置密码频率限制触发:username=$username")
return Result.failure(IllegalStateException("重置失败"))
}
)
// 验证重置密钥
if (resetKey != resetPasswordKey) {
logger.warn("重置密码失败:重置密钥错误,username=$username")
return Result.failure(IllegalArgumentException("重置失败"))
}
// 验证密码强度
if (!checkPasswordStrength(newPassword)) {
logger.warn("重置密码失败:密码强度不符合要求,username=$username")
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_PASSWORD_WEAK.message))
}
// 检查用户是否存在
val existingUser = userRepository.findByUsername(username)
if (existingUser != null) {
// 用户存在,更新密码
val encodedPassword = passwordEncoder.encode(newPassword)
val updatedUser = existingUser.copy(
password = encodedPassword,
updatedAt = System.currentTimeMillis()
)
userRepository.save(updatedUser)
logger.info("密码重置成功:username=$username")
} else {
// 用户不存在,检查是否是首次使用
val isFirstUse = userRepository.count() == 0L
if (isFirstUse) {
// 首次使用,创建新用户(设置为默认账户)
val encodedPassword = passwordEncoder.encode(newPassword)
val newUser = User(
username = username,
password = encodedPassword,
isDefault = true, // 首次创建的用户为默认账户
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
userRepository.save(newUser)
logger.info("首次使用,创建默认账户成功:username=$username")
} else {
// 不是首次使用,用户不存在
logger.warn("重置密码失败:用户不存在,username=$username")
return Result.failure(IllegalArgumentException("重置失败"))
}
}
Result.success(Unit)
} catch (e: Exception) {
logger.error("重置密码异常:username=$username", e)
Result.failure(e)
}
}
/**
* 刷新token
*/
fun refreshToken(token: String): Result<String> {
return try {
if (!jwtUtils.validateToken(token)) {
return Result.failure(IllegalArgumentException(ErrorCode.AUTH_TOKEN_INVALID.message))
}
if (!jwtUtils.isTokenExpiring(token)) {
// 不需要刷新,返回原token
return Result.success(token)
}
// 获取用户名并生成新token
val username = jwtUtils.getUsernameFromToken(token)
?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_TOKEN_INVALID.message))
val newToken = jwtUtils.generateToken(username)
logger.debug("Token刷新成功:username=$username")
Result.success(newToken)
} catch (e: Exception) {
logger.error("刷新token异常", e)
Result.failure(e)
}
}
/**
* 检查是否首次使用(数据库中是否有用户)
*/
fun isFirstUse(): Boolean {
return userRepository.count() == 0L
}
/**
* 验证密码强度
* 至少6位
*/
private fun checkPasswordStrength(password: String): Boolean {
return password.length >= 6
}
/**
* 获取客户端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.getHeader("WL-Proxy-Client-IP")
}
if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) {
ip = request.remoteAddr
}
// 处理多个IP的情况(X-Forwarded-For可能包含多个IP
if (ip.contains(",")) {
ip = ip.split(",")[0].trim()
}
return ip
}
}
@@ -0,0 +1,52 @@
package com.wrbug.polymarketbot.service
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
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
@Value("\${rate-limit.reset-password.window-seconds:60}")
private var windowSeconds: Long = 60
// 全局尝试记录列表(时间戳),所有请求共享
private val resetPasswordAttempts = AtomicReference<MutableList<Long>>(mutableListOf())
/**
* 检查重置密码频率限制(全局限制,不按IP)
* @return Result,如果超过限制则返回失败
*/
fun checkResetPasswordRateLimit(): Result<Unit> {
val now = System.currentTimeMillis()
val windowStart = now - (windowSeconds * 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}次,请稍后再试"))
}
// 记录本次尝试
validAttempts.add(now)
resetPasswordAttempts.set(validAttempts)
return Result.success(Unit)
}
}
@@ -0,0 +1,245 @@
package com.wrbug.polymarketbot.service
import com.wrbug.polymarketbot.dto.UserCreateRequest
import com.wrbug.polymarketbot.dto.UserDto
import com.wrbug.polymarketbot.dto.UserUpdatePasswordRequest
import com.wrbug.polymarketbot.entity.User
import com.wrbug.polymarketbot.repository.UserRepository
import org.slf4j.LoggerFactory
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
/**
* 用户管理服务
*/
@Service
class UserService(
private val userRepository: UserRepository
) {
private val logger = LoggerFactory.getLogger(UserService::class.java)
private val passwordEncoder = BCryptPasswordEncoder()
/**
* 检查当前用户是否为默认账户
*/
fun isDefaultUser(username: String): Boolean {
val user = userRepository.findByUsername(username) ?: return false
return user.isDefault
}
/**
* 获取用户列表
* @param currentUsername 当前登录用户名
* @return 如果是默认账户,返回所有用户;否则只返回当前用户
*/
fun getUserList(currentUsername: String): List<UserDto> {
val isDefault = isDefaultUser(currentUsername)
if (isDefault) {
// 默认账户:返回所有用户
val users = userRepository.findAllByOrderByCreatedAtAsc()
return users.map { user ->
UserDto(
id = user.id!!,
username = user.username,
isDefault = user.isDefault,
createdAt = user.createdAt,
updatedAt = user.updatedAt
)
}
} else {
// 非默认账户:只返回当前用户
val user = userRepository.findByUsername(currentUsername)
if (user != null) {
return listOf(
UserDto(
id = user.id!!,
username = user.username,
isDefault = user.isDefault,
createdAt = user.createdAt,
updatedAt = user.updatedAt
)
)
}
return emptyList()
}
}
/**
* 创建用户
* 注意:此方法只允许默认账户调用
*/
@Transactional
fun createUser(request: UserCreateRequest, currentUsername: String): Result<UserDto> {
return try {
// 验证当前用户是否为默认账户(双重验证,确保安全)
val currentUser = userRepository.findByUsername(currentUsername)
?: return Result.failure(IllegalArgumentException("当前用户不存在"))
if (!currentUser.isDefault) {
logger.warn("非默认账户尝试创建用户:currentUser=$currentUsername")
return Result.failure(IllegalStateException("只有默认账户可以创建用户"))
}
// 验证用户名
if (request.username.isBlank()) {
return Result.failure(IllegalArgumentException("用户名不能为空"))
}
// 验证密码
if (request.password.length < 6) {
return Result.failure(IllegalArgumentException("密码长度不符合要求,至少6位"))
}
// 检查用户名是否已存在
if (userRepository.existsByUsername(request.username)) {
return Result.failure(IllegalArgumentException("用户名已存在"))
}
// 创建用户
val encodedPassword = passwordEncoder.encode(request.password)
val newUser = User(
username = request.username,
password = encodedPassword,
isDefault = false,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
val savedUser = userRepository.save(newUser)
logger.info("创建用户成功:username=${request.username}, createdBy=$currentUsername")
Result.success(UserDto(
id = savedUser.id!!,
username = savedUser.username,
isDefault = savedUser.isDefault,
createdAt = savedUser.createdAt,
updatedAt = savedUser.updatedAt
))
} catch (e: Exception) {
logger.error("创建用户异常:username=${request.username}", e)
Result.failure(e)
}
}
/**
* 更新用户密码(管理员修改其他用户密码)
* 注意:此方法只允许默认账户调用,且只能修改非默认账户的密码
*/
@Transactional
fun updateUserPassword(request: UserUpdatePasswordRequest, currentUsername: String): Result<Unit> {
return try {
// 验证当前用户是否为默认账户(双重验证,确保安全)
val currentUser = userRepository.findByUsername(currentUsername)
?: return Result.failure(IllegalArgumentException("当前用户不存在"))
if (!currentUser.isDefault) {
logger.warn("非默认账户尝试更新用户密码:currentUser=$currentUsername")
return Result.failure(IllegalStateException("只有默认账户可以更新用户密码"))
}
// 验证密码
if (request.newPassword.length < 6) {
return Result.failure(IllegalArgumentException("密码长度不符合要求,至少6位"))
}
// 查找目标用户
val targetUser = userRepository.findById(request.userId).orElse(null)
?: return Result.failure(IllegalArgumentException("用户不存在"))
// 不能修改默认账户的密码(通过用户管理接口)
if (targetUser.isDefault) {
return Result.failure(IllegalArgumentException("不能修改默认账户的密码"))
}
// 更新密码
val encodedPassword = passwordEncoder.encode(request.newPassword)
val updatedUser = targetUser.copy(
password = encodedPassword,
updatedAt = System.currentTimeMillis()
)
userRepository.save(updatedUser)
logger.info("更新用户密码成功:userId=${request.userId}, username=${targetUser.username}, updatedBy=$currentUsername")
Result.success(Unit)
} catch (e: Exception) {
logger.error("更新用户密码异常:userId=${request.userId}, currentUser=$currentUsername", e)
Result.failure(e)
}
}
/**
* 用户修改自己的密码
* 注意:此方法从JWT中获取用户名,确保用户只能修改自己的密码
*/
@Transactional
fun updateOwnPassword(newPassword: String, currentUsername: String): Result<Unit> {
return try {
// 验证密码
if (newPassword.length < 6) {
return Result.failure(IllegalArgumentException("密码长度不符合要求,至少6位"))
}
// 从数据库查找当前用户(确保用户存在,且用户名来自JWT,不可篡改)
val user = userRepository.findByUsername(currentUsername)
?: return Result.failure(IllegalArgumentException("用户不存在"))
// 更新密码(只更新当前用户的密码,不依赖任何请求参数中的用户ID)
val encodedPassword = passwordEncoder.encode(newPassword)
val updatedUser = user.copy(
password = encodedPassword,
updatedAt = System.currentTimeMillis()
)
userRepository.save(updatedUser)
logger.info("用户修改自己密码成功:username=$currentUsername, userId=${user.id}")
Result.success(Unit)
} catch (e: Exception) {
logger.error("用户修改自己密码异常:username=$currentUsername", e)
Result.failure(e)
}
}
/**
* 删除用户
* 注意:此方法只允许默认账户调用,且不能删除默认账户和自己
*/
@Transactional
fun deleteUser(userId: Long, currentUsername: String): Result<Unit> {
return try {
// 验证当前用户是否为默认账户(双重验证,确保安全)
val currentUser = userRepository.findByUsername(currentUsername)
?: return Result.failure(IllegalArgumentException("当前用户不存在"))
if (!currentUser.isDefault) {
logger.warn("非默认账户尝试删除用户:currentUser=$currentUsername")
return Result.failure(IllegalStateException("只有默认账户可以删除用户"))
}
// 查找目标用户
val targetUser = userRepository.findById(userId).orElse(null)
?: return Result.failure(IllegalArgumentException("用户不存在"))
// 不能删除默认账户
if (targetUser.isDefault) {
return Result.failure(IllegalArgumentException("不能删除默认账户"))
}
// 不能删除自己(通过JWT中的用户名验证,不可篡改)
if (targetUser.username == currentUsername) {
return Result.failure(IllegalArgumentException("不能删除自己"))
}
// 删除用户
userRepository.delete(targetUser)
logger.info("删除用户成功:userId=$userId, username=${targetUser.username}, deletedBy=$currentUsername")
Result.success(Unit)
} catch (e: Exception) {
logger.error("删除用户异常:userId=$userId, currentUser=$currentUsername", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,132 @@
package com.wrbug.polymarketbot.util
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.util.*
import javax.crypto.SecretKey
/**
* JWT工具类
*/
@Component
class JwtUtils {
@Value("\${jwt.secret}")
private lateinit var secret: String
@Value("\${jwt.expiration}")
private var expiration: Long = 604800000 // 7天,默认值
@Value("\${jwt.refresh-threshold}")
private var refreshThreshold: Long = 86400000 // 1天,默认值
/**
* 获取签名密钥
*/
private fun getSigningKey(): SecretKey {
return Keys.hmacShaKeyFor(secret.toByteArray())
}
/**
* 生成JWT token
* @param username 用户名
* @return JWT token字符串
*/
fun generateToken(username: String): String {
val now = Date()
val expiryDate = Date(now.time + expiration)
return Jwts.builder()
.subject(username)
.issuedAt(now)
.expiration(expiryDate)
.signWith(getSigningKey())
.compact()
}
/**
* 从token中获取Claims
*/
private fun getClaimsFromToken(token: String): Claims? {
return try {
Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.payload
} catch (e: Exception) {
null
}
}
/**
* 验证token是否有效
*/
fun validateToken(token: String): Boolean {
return try {
val claims = getClaimsFromToken(token)
claims != null && !isTokenExpired(token)
} catch (e: Exception) {
false
}
}
/**
* 从token中获取用户名
*/
fun getUsernameFromToken(token: String): String? {
return try {
val claims = getClaimsFromToken(token)
claims?.subject
} catch (e: Exception) {
null
}
}
/**
* 获取token签发时间(毫秒时间戳)
*/
fun getIssuedAtFromToken(token: String): Long? {
return try {
val claims = getClaimsFromToken(token)
claims?.issuedAt?.time
} catch (e: Exception) {
null
}
}
/**
* 判断token是否已过期
*/
fun isTokenExpired(token: String): Boolean {
return try {
val claims = getClaimsFromToken(token)
val expiration = claims?.expiration ?: return true
expiration.before(Date())
} catch (e: Exception) {
true
}
}
/**
* 判断token是否使用超过1天但未过期(用于自动刷新)
*/
fun isTokenExpiring(token: String): Boolean {
return try {
if (isTokenExpired(token)) {
return false
}
val issuedAt = getIssuedAtFromToken(token) ?: return false
val now = System.currentTimeMillis()
val timeSinceIssued = now - issuedAt
// 如果使用时间超过刷新阈值(1天)但未过期,则需要刷新
timeSinceIssued >= refreshThreshold
} catch (e: Exception) {
false
}
}
}
@@ -56,3 +56,19 @@ copy.trading.polling.enabled=${COPY_TRADING_POLLING_ENABLED:true}
# WebSocket 配置
websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000}
# JWT配置
jwt.secret=${JWT_SECRET:your-secret-key-change-in-production}
# 7天(毫秒)
jwt.expiration=604800000
# 1天(毫秒),超过此时间但未过期时自动刷新
jwt.refresh-threshold=86400000
# 密码重置配置
admin.reset-password.key=${ADMIN_RESET_PASSWORD_KEY:change-me-in-production}
# 频率限制配置
# 1分钟内最多3次
rate-limit.reset-password.max-attempts=3
# 时间窗口(秒)
rate-limit.reset-password.window-seconds=60
@@ -0,0 +1,10 @@
-- 创建用户表(用于JWT登录鉴权)
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(唯一)',
password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)',
created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)',
updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)',
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表(JWT登录鉴权)';
@@ -0,0 +1,8 @@
-- 添加 is_default 字段到 users 表
ALTER TABLE users
ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户(首次创建的用户)';
-- 为默认账户添加索引
ALTER TABLE users
ADD INDEX idx_is_default (is_default);