backend scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:31:27 +08:00
commit 84fc2c0677
159 changed files with 10542 additions and 0 deletions
+9
View File
@@ -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'
}
@@ -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
}
}
@@ -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, "没有权限执行该操作"),
)
}
}
@@ -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)
}
}
@@ -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 字节,当前配置不满足"
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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("未绑定门店的请求不应到达这里")
}
@@ -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-tripclaims 和 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) }
}
}
@@ -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()
}
}
@@ -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,
)