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:
@@ -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,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}
|
||||
Reference in New Issue
Block a user