feat(auth): implement JWT authentication

- Add JwtUtils for token generation and validation using jjwt
- Refactor AuthService.login to return User instead of Boolean
- Add jjwt dependencies and integrate JWT into login flow
- Externalize JWT secret, expiration, and log level as configurable env vars with defaults
This commit is contained in:
2026-05-21 14:04:00 +08:00
parent 3937224341
commit 44b8326e96
6 changed files with 108 additions and 21 deletions
+3
View File
@@ -30,6 +30,9 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.0")
implementation("io.jsonwebtoken:jjwt-api:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.13.0")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.13.0")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
@@ -0,0 +1,71 @@
package com.msksbr.bookmgr.config
import com.msksbr.bookmgr.script.log
import io.jsonwebtoken.Claims
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
import javax.crypto.SecretKey
/*
* 注册JWT的失效时间和密钥
*/
@Component
class JwtUtils(
@Value("\${jwt.secret}") private val configuredSecret: String,
@Value("\${jwt.expiration}") private val expiration: Long
) {
private val secretKey: SecretKey
// 没有配置key时自动生成
init {
val keyBytes = if (configuredSecret.isNotBlank()) {
// 把用户提供的secret,用SHA-256哈希成256字节固定长度
val md = MessageDigest.getInstance("SHA-256")
md.digest(configuredSecret.toByteArray())
} else {
val bytes = ByteArray(64)
SecureRandom().nextBytes(bytes)
val generated = Base64.getEncoder().encodeToString(bytes)
log.warn("JWT secret not configured, auto-generated: $generated")
log.warn("Set JWT_SECRET env var to persist across restarts")
generated.toByteArray()
}
secretKey = Keys.hmacShaKeyFor(keyBytes)
}
// 生成token
fun generateToken(username: String, role: String): String {
log.debug("Generating token for user $username, $role")
val claims = Jwts.claims().add("role", role).build()
val token = Jwts.builder().claims(claims).subject(username)
.issuedAt(Date()).expiration(
Date(
System.currentTimeMillis()
+ expiration
)
).signWith(secretKey).compact()
log.debug("Token generated $token")
return token
}
// 解析token
fun parseToken(token: String): Claims? {
try {
log.debug("Parsing token $token")
val claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token)
log.debug("Token parsed: ${claims.payload.subject}")
return claims.payload
} catch (e: JwtException) {
log.error("Token parsing failed", e)
} catch (e: IllegalArgumentException) {
log.error("Token parsing failed with illegalArguments: $token", e)
}
return null
}
}
@@ -1,12 +1,12 @@
package com.msksbr.bookmgr.controller
import com.msksbr.bookmgr.config.IpExtractor
import com.msksbr.bookmgr.config.JwtUtils
import com.msksbr.bookmgr.dto.UserLoginDTO
import com.msksbr.bookmgr.script.log
import com.msksbr.bookmgr.service.AuthService
import com.msksbr.bookmgr.template.Result
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpSession
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
@@ -21,25 +21,31 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/auth")
class AuthController(
val authService: AuthService,
val ipExtractor: IpExtractor
val ipExtractor: IpExtractor,
private val jwtUtils: JwtUtils
) {
@PostMapping("/login")
fun login(
@Valid
@RequestBody
loginDTO: UserLoginDTO,
session: HttpSession,
request: HttpServletRequest
): Result<String> {
): Result<out Any> {
log.info("Login from ${ipExtractor.getRealIp(request)} username: ${loginDTO.username}")
log.info("UA: ${request.getHeader("User-Agent")}")
// 调用service验证
val pass = authService.login(loginDTO)
return if (pass) {
// 登录成功,存入session
session.setAttribute("loginUser", loginDTO.username)
val user = authService.login(loginDTO)
return if (user != null) {
// 登录成功,返回JWT
val token = jwtUtils.generateToken(user.username, user.role)
log.info("Login success")
Result.success("login success")
Result.success(
mapOf(
"token" to token,
"username" to user.username,
"role" to user.role
)
)
} else {
log.info("Login failed")
Result.error("username or password not match")
@@ -48,13 +54,11 @@ class AuthController(
@PostMapping("/logout")
fun logout(
session: HttpSession,
request: HttpServletRequest
): Result<String> {
log.info("Logout from ${ipExtractor.getRealIp(request)}")
log.info("UA: ${request.getHeader("User-Agent")}")
// 直接销毁session
session.invalidate()
// JWT无状态,只需返回成功让客户端自行删除token即可
log.info("Logout success")
return Result.success("logout successfully")
}
@@ -1,7 +1,8 @@
package com.msksbr.bookmgr.service
import com.msksbr.bookmgr.dto.UserLoginDTO
import com.msksbr.bookmgr.entity.User
interface AuthService {
fun login(loginDTO: UserLoginDTO): Boolean
fun login(loginDTO: UserLoginDTO): User?
}
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service
@Service
class AuthServiceImpl(private val userMapper: UserMapper, private val passwordEncoder: PasswordEncoder) : AuthService {
override fun login(loginDTO: UserLoginDTO): Boolean {
override fun login(loginDTO: UserLoginDTO): User? {
// 数据查询
log.debug("Select user{username=${loginDTO.username}} from database")
val user = userMapper.selectOne(
@@ -23,9 +23,13 @@ class AuthServiceImpl(private val userMapper: UserMapper, private val passwordEn
log.debug("User ${loginDTO.username} does not exist")
// 跑一遍dummyHash来对齐响应时间,避免“信息泄露”攻击
passwordEncoder.encode("dummyHash")
return false
return null
}
// 比对密码
return passwordEncoder.matches(loginDTO.password, user.password)
return if(passwordEncoder.matches(loginDTO.password, user.password)){
user
}else{
null
}
}
}
+9 -5
View File
@@ -9,8 +9,8 @@ spring:
jackson:
default-property-inclusion: non_null
property-naming-strategy: SNAKE_CASE
date-format: ${JSON_DATE_FORMAT}
time-zone: ${JSON_TIME_ZONE}
date-format: ${JSON_DATE_FORMAT:}
time-zone: ${JSON_TIME_ZONE:}
mybatis-plus:
configuration:
@@ -18,6 +18,10 @@ mybatis-plus:
map-underscore-to-camel-case: true
# 开启日志输出sql语句
# log-impl: org.apache.ibatis.logging.commons.JakartaCommonsLoggingImpl
# logging:
# level:
# com.msksbr.bookmgr: "DEBUG"
logging:
level:
com.msksbr.bookmgr: ${LOG_LEVEL:INFO}
jwt:
secret: ${JWT_SECRET:}
expiration: ${JWT_EXPIRATION_TIME:86400000}