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("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.0") 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.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") 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 package com.msksbr.bookmgr.controller
import com.msksbr.bookmgr.config.IpExtractor import com.msksbr.bookmgr.config.IpExtractor
import com.msksbr.bookmgr.config.JwtUtils
import com.msksbr.bookmgr.dto.UserLoginDTO import com.msksbr.bookmgr.dto.UserLoginDTO
import com.msksbr.bookmgr.script.log import com.msksbr.bookmgr.script.log
import com.msksbr.bookmgr.service.AuthService import com.msksbr.bookmgr.service.AuthService
import com.msksbr.bookmgr.template.Result import com.msksbr.bookmgr.template.Result
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpSession
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
@@ -21,25 +21,31 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
class AuthController( class AuthController(
val authService: AuthService, val authService: AuthService,
val ipExtractor: IpExtractor val ipExtractor: IpExtractor,
private val jwtUtils: JwtUtils
) { ) {
@PostMapping("/login") @PostMapping("/login")
fun login( fun login(
@Valid @Valid
@RequestBody @RequestBody
loginDTO: UserLoginDTO, loginDTO: UserLoginDTO,
session: HttpSession,
request: HttpServletRequest request: HttpServletRequest
): Result<String> { ): Result<out Any> {
log.info("Login from ${ipExtractor.getRealIp(request)} username: ${loginDTO.username}") log.info("Login from ${ipExtractor.getRealIp(request)} username: ${loginDTO.username}")
log.info("UA: ${request.getHeader("User-Agent")}") log.info("UA: ${request.getHeader("User-Agent")}")
// 调用service验证 // 调用service验证
val pass = authService.login(loginDTO) val user = authService.login(loginDTO)
return if (pass) { return if (user != null) {
// 登录成功,存入session // 登录成功,返回JWT
session.setAttribute("loginUser", loginDTO.username) val token = jwtUtils.generateToken(user.username, user.role)
log.info("Login success") log.info("Login success")
Result.success("login success") Result.success(
mapOf(
"token" to token,
"username" to user.username,
"role" to user.role
)
)
} else { } else {
log.info("Login failed") log.info("Login failed")
Result.error("username or password not match") Result.error("username or password not match")
@@ -48,13 +54,11 @@ class AuthController(
@PostMapping("/logout") @PostMapping("/logout")
fun logout( fun logout(
session: HttpSession,
request: HttpServletRequest request: HttpServletRequest
): Result<String> { ): Result<String> {
log.info("Logout from ${ipExtractor.getRealIp(request)}") log.info("Logout from ${ipExtractor.getRealIp(request)}")
log.info("UA: ${request.getHeader("User-Agent")}") log.info("UA: ${request.getHeader("User-Agent")}")
// 直接销毁session // JWT无状态,只需返回成功让客户端自行删除token即可
session.invalidate()
log.info("Logout success") log.info("Logout success")
return Result.success("logout successfully") return Result.success("logout successfully")
} }
@@ -1,7 +1,8 @@
package com.msksbr.bookmgr.service package com.msksbr.bookmgr.service
import com.msksbr.bookmgr.dto.UserLoginDTO import com.msksbr.bookmgr.dto.UserLoginDTO
import com.msksbr.bookmgr.entity.User
interface AuthService { interface AuthService {
fun login(loginDTO: UserLoginDTO): Boolean fun login(loginDTO: UserLoginDTO): User?
} }
@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service
@Service @Service
class AuthServiceImpl(private val userMapper: UserMapper, private val passwordEncoder: PasswordEncoder) : AuthService { 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") log.debug("Select user{username=${loginDTO.username}} from database")
val user = userMapper.selectOne( 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") log.debug("User ${loginDTO.username} does not exist")
// 跑一遍dummyHash来对齐响应时间,避免“信息泄露”攻击 // 跑一遍dummyHash来对齐响应时间,避免“信息泄露”攻击
passwordEncoder.encode("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: jackson:
default-property-inclusion: non_null default-property-inclusion: non_null
property-naming-strategy: SNAKE_CASE property-naming-strategy: SNAKE_CASE
date-format: ${JSON_DATE_FORMAT} date-format: ${JSON_DATE_FORMAT:}
time-zone: ${JSON_TIME_ZONE} time-zone: ${JSON_TIME_ZONE:}
mybatis-plus: mybatis-plus:
configuration: configuration:
@@ -18,6 +18,10 @@ mybatis-plus:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
# 开启日志输出sql语句 # 开启日志输出sql语句
# log-impl: org.apache.ibatis.logging.commons.JakartaCommonsLoggingImpl # log-impl: org.apache.ibatis.logging.commons.JakartaCommonsLoggingImpl
# logging: logging:
# level: level:
# com.msksbr.bookmgr: "DEBUG" com.msksbr.bookmgr: ${LOG_LEVEL:INFO}
jwt:
secret: ${JWT_SECRET:}
expiration: ${JWT_EXPIRATION_TIME:86400000}