backend scaffold
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
dependencies {
|
||||
// 01-project-structure.md 认可的唯一 platform-* 之间的依赖:platform-security -> platform-web
|
||||
api project(':platform:platform-web')
|
||||
|
||||
api 'org.springframework.boot:spring-boot-starter-security'
|
||||
api 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Clock
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 签发 access token,见 04-security-auth.md。
|
||||
* 门店维度写进 `storeId` claim——**请求头 X-Store-Id 不构成身份,只有签名过的 claim 算数**。
|
||||
*/
|
||||
@Component
|
||||
class AccessTokenIssuer(
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val props: JwtProperties,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
fun issue(userId: Long, storeId: Long, roles: List<String>): String {
|
||||
val now = clock.instant()
|
||||
val claims = JwtClaimsSet.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("storeId", storeId)
|
||||
.claim("roles", roles)
|
||||
.id(UUID.randomUUID().toString())
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(props.accessTokenTtlMinutes, ChronoUnit.MINUTES))
|
||||
.build()
|
||||
val header = JwsHeader.with(MacAlgorithm.HS256).keyId(props.activeKeyId).build()
|
||||
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.continental.retailapp.platform.web.ApiResult
|
||||
import com.continental.retailapp.platform.web.ErrorCode
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.access.AccessDeniedException
|
||||
import org.springframework.security.core.AuthenticationException
|
||||
import org.springframework.security.web.AuthenticationEntryPoint
|
||||
import org.springframework.security.web.access.AccessDeniedHandler
|
||||
import org.springframework.stereotype.Component
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
|
||||
/**
|
||||
* 认证失败(401)也要返回统一的 [ApiResult] 结构,见 04-security-auth.md。
|
||||
* 不接管的话 Spring Security 会直接吐一个空 body 的 401,客户端的统一解析会崩。
|
||||
*/
|
||||
@Component
|
||||
class ApiResultAuthenticationEntryPoint(private val objectMapper: ObjectMapper) : AuthenticationEntryPoint {
|
||||
override fun commence(
|
||||
req: HttpServletRequest,
|
||||
resp: HttpServletResponse,
|
||||
ex: AuthenticationException,
|
||||
) {
|
||||
resp.status = HttpStatus.UNAUTHORIZED.value()
|
||||
resp.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||
resp.characterEncoding = Charsets.UTF_8.name()
|
||||
objectMapper.writeValue(
|
||||
resp.outputStream,
|
||||
ApiResult.error(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 鉴权失败(403)同上。 */
|
||||
@Component
|
||||
class ApiResultAccessDeniedHandler(private val objectMapper: ObjectMapper) : AccessDeniedHandler {
|
||||
override fun handle(
|
||||
req: HttpServletRequest,
|
||||
resp: HttpServletResponse,
|
||||
ex: AccessDeniedException,
|
||||
) {
|
||||
resp.status = HttpStatus.FORBIDDEN.value()
|
||||
resp.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||
resp.characterEncoding = Charsets.UTF_8.name()
|
||||
objectMapper.writeValue(
|
||||
resp.outputStream,
|
||||
ApiResult.error(ErrorCode.FORBIDDEN, "没有权限执行该操作"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm
|
||||
import com.nimbusds.jose.jwk.JWKSet
|
||||
import com.nimbusds.jose.jwk.OctetSequenceKey
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
|
||||
import com.nimbusds.jose.proc.JWSVerificationKeySelector
|
||||
import com.nimbusds.jose.proc.SecurityContext
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
|
||||
import java.util.Base64
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* JWT 编解码器,见 04-security-auth.md。用 Spring Security 自带的 Nimbus 实现而不是 jjwt——
|
||||
* jjwt 的 Jackson 集成还在 Jackson 2,与 Boot 4 的 Jackson 3 会打架。
|
||||
*
|
||||
* 对文档的有意偏离 #2:文档正文要求"轮换期间新旧 token 都能验过",但示例里的
|
||||
* `NimbusJwtDecoder.withSecretKey(secretKey(activeKeyId))` 只装了一把密钥,做不到这件事。
|
||||
* 这里改成把 `keys` 里所有密钥装进 JWKSet,由 `JWSVerificationKeySelector` 按 header 的 kid 选,
|
||||
* 这样才对得上文档写的轮换三步走(先加新密钥 → 再切 activeKeyId → 最后删旧密钥)。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(JwtProperties::class)
|
||||
class JwtEncoderConfig(private val props: JwtProperties) {
|
||||
|
||||
private fun secretKey(keyId: String): SecretKey =
|
||||
SecretKeySpec(Base64.getDecoder().decode(props.keys.getValue(keyId)), "HmacSHA256")
|
||||
|
||||
private fun jwkSource(): ImmutableJWKSet<SecurityContext> {
|
||||
val jwkSet = JWKSet(
|
||||
props.keys.keys.map { kid ->
|
||||
OctetSequenceKey.Builder(secretKey(kid))
|
||||
.keyID(kid)
|
||||
.algorithm(JWSAlgorithm.HS256)
|
||||
.build()
|
||||
},
|
||||
)
|
||||
return ImmutableJWKSet(jwkSet)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun jwtEncoder(): JwtEncoder = NimbusJwtEncoder(jwkSource())
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder {
|
||||
val processor = DefaultJWTProcessor<SecurityContext>().apply {
|
||||
jwsKeySelector = JWSVerificationKeySelector(JWSAlgorithm.HS256, jwkSource())
|
||||
// 只签发/校验自家 token,不需要 Nimbus 默认的 claims 校验器之外的东西
|
||||
}
|
||||
return NimbusJwtDecoder(processor)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import org.springframework.validation.annotation.Validated
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* JWT 配置,见 04-security-auth.md。
|
||||
*
|
||||
* `keys` 是 kid -> base64(密钥) 的映射,支持多把密钥同时在用(轮换期新旧 token 都要能验过)。
|
||||
* 真实密钥来自 K8s Secret(见 07-config-governance.md),仓库和镜像里只有 local profile 的假密钥。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "security.jwt")
|
||||
@Validated
|
||||
data class JwtProperties(
|
||||
@field:NotBlank val activeKeyId: String,
|
||||
val keys: Map<String, String> = emptyMap(),
|
||||
@field:Min(5) val accessTokenTtlMinutes: Long = 30,
|
||||
@field:Min(1) val refreshTokenTtlDays: Long = 30,
|
||||
) {
|
||||
/** 启动即失败:弱密钥或缺 activeKeyId 不允许把服务拉起来。 */
|
||||
@PostConstruct
|
||||
fun validate() {
|
||||
val key = keys[activeKeyId] ?: error("security.jwt.keys 里没有 activeKeyId=$activeKeyId 对应的密钥")
|
||||
require(Base64.getDecoder().decode(key).size >= 32) {
|
||||
"HS256 密钥长度必须 ≥ 32 字节,当前配置不满足"
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import org.springframework.beans.factory.ObjectFactory
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.core.env.Environment
|
||||
import org.springframework.core.env.Profiles
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.crypto.factory.PasswordEncoderFactories
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
|
||||
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
/**
|
||||
* 安全配置,见 04-security-auth.md。
|
||||
*
|
||||
* 无状态资源服务器:没有 session、没有 CSRF token,认证完全靠 Bearer JWT。
|
||||
* swagger 只在非 prod 放开——生产环境暴露接口文档等于把攻击面清单送出去。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig(
|
||||
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
|
||||
private val entryPoint: ApiResultAuthenticationEntryPoint,
|
||||
private val accessDeniedHandler: ApiResultAccessDeniedHandler,
|
||||
private val environment: Environment,
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
val isProd = environment.acceptsProfiles(Profiles.of("prod"))
|
||||
|
||||
http
|
||||
.csrf { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers("/actuator/health/**").permitAll()
|
||||
auth.requestMatchers("/api/v1/auth/login", "/api/v1/auth/refresh").permitAll()
|
||||
if (!isProd) {
|
||||
auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
}
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { oauth2 ->
|
||||
oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(rolesConverter()) }
|
||||
oauth2.authenticationEntryPoint(entryPoint)
|
||||
}
|
||||
.exceptionHandling {
|
||||
it.authenticationEntryPoint(entryPoint)
|
||||
it.accessDeniedHandler(accessDeniedHandler)
|
||||
}
|
||||
.addFilterAfter(
|
||||
StoreContextFilter(storeContextHolder),
|
||||
BearerTokenAuthenticationFilter::class.java,
|
||||
)
|
||||
|
||||
return http.build()
|
||||
}
|
||||
|
||||
/** BCrypt strength 12,见 04-security-auth.md;用 Delegating 是为了将来换算法时老哈希还能验。 */
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
|
||||
|
||||
private fun rolesConverter(): JwtAuthenticationConverter {
|
||||
val authorities = JwtGrantedAuthoritiesConverter().apply {
|
||||
setAuthoritiesClaimName("roles")
|
||||
setAuthorityPrefix("ROLE_")
|
||||
}
|
||||
return JwtAuthenticationConverter().apply {
|
||||
setJwtGrantedAuthoritiesConverter(authorities)
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.beans.factory.ObjectFactory
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
|
||||
/**
|
||||
* 把 JWT 里的身份/门店信息搬进 [StoreContextHolder],见 04-security-auth.md。
|
||||
* 注册位置在 `BearerTokenAuthenticationFilter` 之后(见 SecurityConfig),保证此时认证已经完成。
|
||||
*/
|
||||
class StoreContextFilter(
|
||||
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
|
||||
) : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
chain: FilterChain,
|
||||
) {
|
||||
val jwt = (SecurityContextHolder.getContext().authentication as? JwtAuthenticationToken)?.token
|
||||
if (jwt != null) {
|
||||
val ctx = storeContextHolder.`object`
|
||||
ctx.userId = jwt.subject?.toLongOrNull()
|
||||
ctx.storeId = jwt.getClaim<Number>("storeId")?.toLong()
|
||||
ctx.roles = jwt.getClaimAsStringList("roles") ?: emptyList()
|
||||
// X-Store-Id 只用于对账排查,**永远以 token 里的 storeId 为准**
|
||||
request.getHeader("X-Store-Id")?.toLongOrNull()?.let { headerStoreId ->
|
||||
if (headerStoreId != ctx.storeId) {
|
||||
logger.warn("X-Store-Id($headerStoreId) 与 token storeId(${ctx.storeId}) 不一致,以 token 为准")
|
||||
}
|
||||
}
|
||||
}
|
||||
chain.doFilter(request, response)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.continental.retailapp.platform.web.AuditContext
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.context.annotation.RequestScope
|
||||
|
||||
/**
|
||||
* 当前请求的门店上下文,见 04-security-auth.md。
|
||||
*
|
||||
* 用 `@RequestScope` bean 而不是 ThreadLocal:容器负责在请求结束时清理,
|
||||
* 不会出现忘记 remove 导致的线程池串味。并行聚合的子线程靠
|
||||
* `ContextPropagatingTaskDecorator` 传播(见 11-cross-domain-collaboration.md)。
|
||||
*
|
||||
* 实现 [AuditContext] 是为了让 platform-persistence / platform-observability 不用反向依赖本模块。
|
||||
*/
|
||||
@Component
|
||||
@RequestScope
|
||||
class StoreContextHolder : AuditContext {
|
||||
override var userId: Long? = null
|
||||
override var storeId: Long? = null
|
||||
var roles: List<String> = emptyList()
|
||||
|
||||
fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里")
|
||||
|
||||
fun currentStoreId(): Long = storeId ?: throw IllegalStateException("未绑定门店的请求不应到达这里")
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW
|
||||
import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW_V2
|
||||
import com.continental.retailapp.platform.security.fixtures.aJwtProperties
|
||||
import com.continental.retailapp.platform.security.fixtures.base64Of
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.security.oauth2.jwt.JwtException
|
||||
import java.time.Clock
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* 签发 → 解码 round-trip:claims 和 kid 都要对得上(04-security-auth.md)。
|
||||
*
|
||||
* 直接 new 出 [JwtEncoderConfig],不起 Spring 容器——这一层没有任何需要容器才能装配的东西。
|
||||
*/
|
||||
class AccessTokenIssuerTest {
|
||||
|
||||
// 签发端的时钟可以固定,**解码端的不行**:`exp` 校验发生在 Nimbus 的
|
||||
// DefaultJWTProcessor 里,用的是真实系统时间,注不进去。
|
||||
// 所以基准点取真实当下(截断到秒,因为 JWT 的 iat/exp 只有秒精度),
|
||||
// 再由各测试用例围绕它做加减——单个用例内部仍然是确定的。
|
||||
private val now = Clock.systemUTC().instant().truncatedTo(ChronoUnit.SECONDS)
|
||||
private val clock = Clock.fixed(now, ZoneOffset.UTC)
|
||||
|
||||
private fun issuerFor(props: JwtProperties): Pair<AccessTokenIssuer, JwtEncoderConfig> {
|
||||
val config = JwtEncoderConfig(props)
|
||||
return AccessTokenIssuer(config.jwtEncoder(), props, clock) to config
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `签发的 token 能被解回原样的 claims`() {
|
||||
val props = aJwtProperties()
|
||||
val (issuer, config) = issuerFor(props)
|
||||
|
||||
val token = issuer.issue(userId = 42L, storeId = 100L, roles = listOf("STORE_MANAGER", "STAFF"))
|
||||
val decoded = config.jwtDecoder().decode(token)
|
||||
|
||||
assertEquals("42", decoded.subject)
|
||||
assertEquals(100L, decoded.getClaim<Long>("storeId"))
|
||||
assertEquals(listOf("STORE_MANAGER", "STAFF"), decoded.getClaimAsStringList("roles"))
|
||||
assertEquals(now, decoded.issuedAt)
|
||||
assertEquals(now.plusSeconds(30 * 60), decoded.expiresAt)
|
||||
assertNotNull(decoded.id) // jti,审计日志里用来串联同一次会话
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `token 头里带 activeKeyId 作为 kid`() {
|
||||
val props = aJwtProperties(
|
||||
activeKeyId = "v2",
|
||||
keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)),
|
||||
)
|
||||
val (issuer, config) = issuerFor(props)
|
||||
|
||||
val decoded = config.jwtDecoder().decode(issuer.issue(1L, 100L, listOf("STAFF")))
|
||||
|
||||
assertEquals("v2", decoded.headers["kid"])
|
||||
assertEquals("HS256", decoded.headers["alg"].toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `轮换期间旧 kid 签发的 token 仍然能验过`() {
|
||||
// 轮换第一步:只加密钥,activeKeyId 还指向 v1,此时签出来的是 v1 的 token
|
||||
val beforeSwitch = aJwtProperties(
|
||||
activeKeyId = "v1",
|
||||
keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)),
|
||||
)
|
||||
val (oldIssuer, _) = issuerFor(beforeSwitch)
|
||||
val oldToken = oldIssuer.issue(1L, 100L, listOf("STAFF"))
|
||||
|
||||
// 轮换第二步:activeKeyId 切到 v2,两把密钥都还在
|
||||
val afterSwitch = aJwtProperties(
|
||||
activeKeyId = "v2",
|
||||
keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)),
|
||||
)
|
||||
val decoder = JwtEncoderConfig(afterSwitch).jwtDecoder()
|
||||
|
||||
// 这条就是对文档偏离 #2 的理由:单密钥的 withSecretKey 解码器在这里会直接失败,
|
||||
// 已经发出去的 access token 会全体作废
|
||||
assertEquals("1", decoder.decode(oldToken).subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `轮换第三步移除旧密钥后,旧 token 不再被接受`() {
|
||||
val beforeSwitch = aJwtProperties(
|
||||
activeKeyId = "v1",
|
||||
keys = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW), "v2" to base64Of(TEST_JWT_SECRET_RAW_V2)),
|
||||
)
|
||||
val (oldIssuer, _) = issuerFor(beforeSwitch)
|
||||
val oldToken = oldIssuer.issue(1L, 100L, listOf("STAFF"))
|
||||
|
||||
val onlyV2 = aJwtProperties(activeKeyId = "v2", keys = mapOf("v2" to base64Of(TEST_JWT_SECRET_RAW_V2)))
|
||||
val decoder = JwtEncoderConfig(onlyV2).jwtDecoder()
|
||||
|
||||
assertThrows(JwtException::class.java) { decoder.decode(oldToken) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `被篡改的 token 验签失败`() {
|
||||
val props = aJwtProperties()
|
||||
val (issuer, config) = issuerFor(props)
|
||||
val token = issuer.issue(userId = 42L, storeId = 100L, roles = listOf("STAFF"))
|
||||
|
||||
// 只动 payload 不动签名:storeId 造假是这套鉴权最直接的攻击面
|
||||
val parts = token.split(".")
|
||||
val tampered = "${parts[0]}.${parts[1].dropLast(2)}XX.${parts[2]}"
|
||||
|
||||
assertThrows(JwtException::class.java) { config.jwtDecoder().decode(tampered) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `过期的 token 解码失败`() {
|
||||
val props = aJwtProperties(accessTokenTtlMinutes = 5)
|
||||
val expiredIssuer = AccessTokenIssuer(
|
||||
JwtEncoderConfig(props).jwtEncoder(),
|
||||
props,
|
||||
Clock.fixed(now.minusSeconds(3600), ZoneOffset.UTC),
|
||||
)
|
||||
val token = expiredIssuer.issue(1L, 100L, listOf("STAFF"))
|
||||
|
||||
assertThrows(JwtException::class.java) { JwtEncoderConfig(props).jwtDecoder().decode(token) }
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.continental.retailapp.platform.security
|
||||
|
||||
import com.continental.retailapp.platform.security.fixtures.TEST_JWT_SECRET_RAW
|
||||
import com.continental.retailapp.platform.security.fixtures.base64Of
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner
|
||||
import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
|
||||
/**
|
||||
* 密钥强度是**启动期**的事,不是运行期的事:配错了就不该让服务活着接流量。
|
||||
* 所以这里用 [ApplicationContextRunner] 起一个真的上下文,验证 `@PostConstruct` 确实把它拦下来了,
|
||||
* 而不是直接调 `validate()` ——后者只能证明方法本身好使,证明不了它会被触发。
|
||||
*/
|
||||
class JwtPropertiesTest {
|
||||
|
||||
private val runner = ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(ValidationAutoConfiguration::class.java))
|
||||
.withUserConfiguration(JwtEncoderConfig::class.java)
|
||||
|
||||
@Test
|
||||
fun `密钥合规时正常启动并装配好编解码器`() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"security.jwt.active-key-id=v1",
|
||||
"security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}",
|
||||
)
|
||||
.run { context ->
|
||||
assertNull(context.startupFailure) { "期望启动成功,实际:${context.startupFailure}" }
|
||||
assertNotNull(context.getBean(JwtEncoder::class.java))
|
||||
assertNotNull(context.getBean(JwtDecoder::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `密钥不足 32 字节时启动失败`() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"security.jwt.active-key-id=v1",
|
||||
"security.jwt.keys.v1=${base64Of("too-short-16byte")}",
|
||||
)
|
||||
.run { context ->
|
||||
val failure = context.startupFailure
|
||||
assertNotNull(failure) { "弱密钥必须让启动失败,不能带病上线" }
|
||||
assertTrue(rootCauseMessageOf(failure!!).contains("32")) {
|
||||
"错误信息里要写清楚是长度问题:${rootCauseMessageOf(failure)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activeKeyId 在 keys 里找不到对应密钥时启动失败`() {
|
||||
runner
|
||||
.withPropertyValues(
|
||||
"security.jwt.active-key-id=v9",
|
||||
"security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}",
|
||||
)
|
||||
.run { context ->
|
||||
val failure = context.startupFailure
|
||||
assertNotNull(failure) { "activeKeyId 指向不存在的密钥必须启动失败" }
|
||||
assertTrue(rootCauseMessageOf(failure!!).contains("activeKeyId")) {
|
||||
rootCauseMessageOf(failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `activeKeyId 为空时校验不通过`() {
|
||||
runner
|
||||
.withPropertyValues("security.jwt.active-key-id=", "security.jwt.keys.v1=${base64Of(TEST_JWT_SECRET_RAW)}")
|
||||
.run { context ->
|
||||
assertNotNull(context.startupFailure) { "空 activeKeyId 必须启动失败" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun rootCauseMessageOf(throwable: Throwable): String {
|
||||
var current = throwable
|
||||
while (current.cause != null && current.cause !== current) {
|
||||
current = current.cause!!
|
||||
}
|
||||
return current.message.orEmpty()
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.continental.retailapp.platform.security.fixtures
|
||||
|
||||
import com.continental.retailapp.platform.security.JwtProperties
|
||||
import java.util.Base64
|
||||
|
||||
/** 32 字节,刚好过 [JwtProperties.validate] 的下限。 */
|
||||
const val TEST_JWT_SECRET_RAW = "conti-test-secret-key-32bytes!!!"
|
||||
|
||||
/** 第二把密钥,用来验证轮换期间新旧 kid 都能验过。 */
|
||||
const val TEST_JWT_SECRET_RAW_V2 = "conti-test-secret-key-32bytes#v2"
|
||||
|
||||
fun base64Of(raw: String): String = Base64.getEncoder().encodeToString(raw.toByteArray())
|
||||
|
||||
fun aJwtProperties(
|
||||
activeKeyId: String = "v1",
|
||||
keys: Map<String, String> = mapOf("v1" to base64Of(TEST_JWT_SECRET_RAW)),
|
||||
accessTokenTtlMinutes: Long = 30,
|
||||
refreshTokenTtlDays: Long = 30,
|
||||
) = JwtProperties(
|
||||
activeKeyId = activeKeyId,
|
||||
keys = keys,
|
||||
accessTokenTtlMinutes = accessTokenTtlMinutes,
|
||||
refreshTokenTtlDays = refreshTokenTtlDays,
|
||||
)
|
||||
Reference in New Issue
Block a user