diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ae9d95..ce1490b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,15 +19,15 @@ jobs: - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1.0.4 - + thundra: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3.0.2 - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Thundra Gradle Test Instrumentation uses: thundra-io/thundra-gradle-test-action@v1.0.4 @@ -60,10 +60,10 @@ jobs: key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-gradle-wrapper- - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Build with Gradle @@ -100,10 +100,10 @@ jobs: key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-gradle-wrapper- - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Test @@ -145,10 +145,10 @@ jobs: key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-gradle-wrapper- - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Test @@ -224,10 +224,10 @@ jobs: key: ${{ runner.os }}-gradle-wrapper-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: ${{ runner.os }}-gradle-wrapper- - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Download artifact diff --git a/.sdkmanrc b/.sdkmanrc index bf93182..e033b50 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1 +1,2 @@ -java=11.0.11.hs-adpt +java=17.0.2-open +kotlin=1.6.21 diff --git a/build.gradle.kts b/build.gradle.kts index 23a3958..aff01ca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,19 +1,19 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - id("org.springframework.boot") version "2.6.4" + id("org.springframework.boot") version "2.7.1" id("io.spring.dependency-management") version "1.0.11.RELEASE" - kotlin("jvm") version "1.6.10" - kotlin("plugin.spring") version "1.6.10" - kotlin("plugin.jpa") version "1.6.10" + kotlin("jvm") version "1.6.21" + kotlin("plugin.spring") version "1.6.21" + kotlin("plugin.jpa") version "1.6.21" jacoco - id("com.adarshr.test-logger") version "3.1.0" + id("com.adarshr.test-logger") version "3.2.0" } group = "com.example" version = "0.0.0" -java.sourceCompatibility = JavaVersion.VERSION_11 +java.sourceCompatibility = JavaVersion.VERSION_17 repositories { mavenCentral() @@ -27,26 +27,26 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") - implementation("io.springfox:springfox-boot-starter:3.0.0") + implementation("org.jetbrains.kotlin:kotlin-stdlib") + implementation("org.springdoc:springdoc-openapi-ui:1.6.9") + implementation("org.springdoc:springdoc-openapi-kotlin:1.6.9") implementation("io.jsonwebtoken:jjwt:0.9.1") + runtimeOnly("mysql:mysql-connector-java") developmentOnly("org.springframework.boot:spring-boot-devtools") - runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("io.mockk:mockk:1.12.2") - testImplementation("com.github.javafaker:javafaker:1.0.2") - testImplementation("io.kotest:kotest-assertions-core:4.6.3") - testImplementation("org.testcontainers:testcontainers:1.16.2") - testImplementation(platform("org.testcontainers:testcontainers-bom:1.16.2")) - testImplementation("org.testcontainers:junit-jupiter:1.16.2") + testImplementation("io.mockk:mockk:1.12.4") + testImplementation("net.datafaker:datafaker:1.4.0") + testImplementation("io.kotest:kotest-assertions-core:5.3.1") + testImplementation(platform("org.testcontainers:testcontainers-bom:1.17.2")) + testImplementation("org.testcontainers:testcontainers") + testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:mysql") - testRuntimeOnly("mysql:mysql-connector-java") } tasks.withType { kotlinOptions { freeCompilerArgs = listOf("-Xjsr305=strict") - jvmTarget = "11" + jvmTarget = "17" } } @@ -71,9 +71,9 @@ tasks.jacocoTestReport { tasks.jacocoTestReport { reports { - csv.isEnabled = false - xml.isEnabled = true - html.isEnabled = true + csv.required.set(false) + xml.required.set(true) + html.required.set(true) } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0da9df6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.7" + +services: + mysql: + image: mysql + command: --default-authentication-plugin=mysql_native_password + restart: always + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: spring_boot_kotlin_realworld + ports: + - "3307:3306" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0f80bbf..ffed3a2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/kotlin/com/example/realworld/configuration/JsonConfiguration.kt b/src/main/kotlin/com/example/realworld/configuration/JsonConfiguration.kt index 34663be..3dcb698 100644 --- a/src/main/kotlin/com/example/realworld/configuration/JsonConfiguration.kt +++ b/src/main/kotlin/com/example/realworld/configuration/JsonConfiguration.kt @@ -1,7 +1,7 @@ package com.example.realworld.configuration import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.PropertyNamingStrategy +import com.fasterxml.jackson.databind.PropertyNamingStrategies import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -10,7 +10,7 @@ class JsonConfiguration { @Bean fun objectMapper(): ObjectMapper { return ObjectMapper().apply { - propertyNamingStrategy = PropertyNamingStrategy.LOWER_CASE + propertyNamingStrategy = PropertyNamingStrategies.LOWER_CASE } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/example/realworld/configuration/SecurityConfiguration.kt b/src/main/kotlin/com/example/realworld/configuration/SecurityConfiguration.kt index 718d5f5..b204d3c 100644 --- a/src/main/kotlin/com/example/realworld/configuration/SecurityConfiguration.kt +++ b/src/main/kotlin/com/example/realworld/configuration/SecurityConfiguration.kt @@ -1,69 +1,76 @@ package com.example.realworld.configuration -import com.example.realworld.filter.AuthorizationFilter +import com.example.realworld.filter.AuthenticationFilter import com.example.realworld.handler.AuthenticationEntryPointHandler import com.example.realworld.repository.UserRepository import com.example.realworld.service.SecurityContextService -import com.example.realworld.service.UserDetailsService import com.example.realworld.util.TokenUtil import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.HttpMethod import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.WebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.SecurityFilterChain + @EnableWebSecurity @Configuration class SecurityConfiguration( - private val userDetailsService: UserDetailsService, private val userRepository: UserRepository, private val tokenUtil: TokenUtil, private val securityContextService: SecurityContextService -) : WebSecurityConfigurerAdapter() { +) { @Bean fun passwordEncoder(): PasswordEncoder { return BCryptPasswordEncoder() } @Bean - override fun authenticationManager(): AuthenticationManager { - return super.authenticationManager() - } - - override fun configure(authenticationManager: AuthenticationManagerBuilder) { - authenticationManager.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()) + fun authenticationManager(authenticationConfiguration: AuthenticationConfiguration): AuthenticationManager { + return authenticationConfiguration.authenticationManager } - override fun configure(http: HttpSecurity) { - http.authorizeRequests() - .antMatchers(HttpMethod.GET, "/").permitAll() - .antMatchers(HttpMethod.GET, "/actuator/**").permitAll() - .antMatchers(HttpMethod.POST, "/api/users").permitAll() - .antMatchers(HttpMethod.POST, "/api/users/login").permitAll() - .anyRequest().authenticated() - .and() - .csrf().disable() - .exceptionHandling().authenticationEntryPoint(AuthenticationEntryPointHandler()) - .and() - .addFilter(AuthorizationFilter(authenticationManager(), tokenUtil, userRepository, securityContextService)) - .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .and() - .cors() + @Bean + fun filterChain(http: HttpSecurity, authenticationManager: AuthenticationManager): SecurityFilterChain { + return http + .authorizeHttpRequests { + it.antMatchers(HttpMethod.GET, "/").permitAll() + .antMatchers(HttpMethod.GET, "/actuator/**").permitAll() + .antMatchers(HttpMethod.POST, "/api/users").permitAll() + .antMatchers(HttpMethod.POST, "/api/users/login").permitAll() + .anyRequest().authenticated() + } + .csrf { it.disable() } + .exceptionHandling { it.authenticationEntryPoint(AuthenticationEntryPointHandler()) } + .addFilter( + AuthenticationFilter( + authenticationManager, + tokenUtil, + userRepository, + securityContextService + ) + ) + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .cors {} + .build() } - override fun configure(web: WebSecurity) { - web.ignoring().antMatchers( - "/v2/api-docs", - "/swagger-resources/**", - "/swagger-ui/**", - "/h2-console/**" - ) + @Bean + fun webSecurityCustomizer(): WebSecurityCustomizer { + return WebSecurityCustomizer { web: WebSecurity -> + web.ignoring().antMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/h2-console/**" + ) + } } } diff --git a/src/main/kotlin/com/example/realworld/configuration/SwaggerConfiguration.kt b/src/main/kotlin/com/example/realworld/configuration/SwaggerConfiguration.kt index d960ea6..f18c281 100644 --- a/src/main/kotlin/com/example/realworld/configuration/SwaggerConfiguration.kt +++ b/src/main/kotlin/com/example/realworld/configuration/SwaggerConfiguration.kt @@ -1,31 +1,36 @@ package com.example.realworld.configuration +import io.swagger.v3.oas.models.ExternalDocumentation +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.info.License import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import springfox.documentation.builders.ApiInfoBuilder -import springfox.documentation.builders.PathSelectors -import springfox.documentation.builders.RequestHandlerSelectors -import springfox.documentation.service.ApiInfo -import springfox.documentation.spi.DocumentationType -import springfox.documentation.spring.web.plugins.Docket @Configuration class SwaggerConfiguration { @Bean - fun api(): Docket { - return Docket(DocumentationType.SWAGGER_2) - .apiInfo(getApiInfo()) - .select() - .apis(RequestHandlerSelectors.any()) - .paths(PathSelectors.any()) - .build() + fun api(): OpenAPI { + return OpenAPI() + .info(getApiInfo()) + .externalDocs(getExternalDocumentation()) } - private fun getApiInfo(): ApiInfo { - return ApiInfoBuilder() + private fun getExternalDocumentation(): ExternalDocumentation { + return ExternalDocumentation() + .description("RealWorld implementation using Spring Boot with Kotlin") + .url("https://github.com/brunohenriquepj/spring-boot-kotlin-realworld") + } + + private fun getApiInfo(): Info { + return Info() .title("RealWorld API Doc") .description("Real World API") + .license( + License() + .name("MIT License") + .url("https://github.com/brunohenriquepj/spring-boot-kotlin-realworld/blob/main/LICENSE") + ) .version("0.0.0") - .build() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/example/realworld/filter/AuthenticationFilter.kt b/src/main/kotlin/com/example/realworld/filter/AuthenticationFilter.kt index 7e333fe..42ee627 100644 --- a/src/main/kotlin/com/example/realworld/filter/AuthenticationFilter.kt +++ b/src/main/kotlin/com/example/realworld/filter/AuthenticationFilter.kt @@ -11,7 +11,8 @@ import javax.servlet.FilterChain import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse -class AuthorizationFilter( + +class AuthenticationFilter( authenticationManager: AuthenticationManager, private val tokenUtil: TokenUtil, private val userRepository: UserRepository, @@ -60,4 +61,4 @@ class AuthorizationFilter( } return header.substring(tokenPrefix.length) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/example/realworld/handler/AuthenticationEntryPointHandler.kt b/src/main/kotlin/com/example/realworld/handler/AuthenticationEntryPointHandler.kt index 0eb56fc..5bd1ed6 100644 --- a/src/main/kotlin/com/example/realworld/handler/AuthenticationEntryPointHandler.kt +++ b/src/main/kotlin/com/example/realworld/handler/AuthenticationEntryPointHandler.kt @@ -9,6 +9,7 @@ import org.springframework.security.web.AuthenticationEntryPoint import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse + class AuthenticationEntryPointHandler : AuthenticationEntryPoint { override fun commence( request: HttpServletRequest, diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index d002268..f7fd573 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -17,10 +17,11 @@ spring.jpa.properties.hibernate.show_sql=true spring.jpa.properties.hibernate.format_sql=true # data source -spring.datasource.driverClassName=org.h2.Driver -spring.datasource.url=jdbc:h2:mem:realworld-api -spring.datasource.username=sa -spring.datasource.password= +# https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-api-changes.html +spring.datasource.driver-class-namee=com.mysql.cj.jdbc.Driver +spring.datasource.url=jdbc:mysql://${MYSQL_HOST:localhost}:3307/spring_boot_kotlin_realworld +spring.datasource.username=root +spring.datasource.password=root # h2 spring.h2.console.enabled=true @@ -29,3 +30,6 @@ spring.h2.console.path=/h2-console # JWT jwt.secret=qXvQkqHzXBe^D0Sz#BTJhY7YHZiAIOSCGJRFWSCxG$hZE!Y!kX@fBIuzYc4FDGN%4^PPHzzgMXoW4sdg&$9JOjF*pgH%S*S8Yo*@ jwt.expiration-milliseconds=600000 + +# open api +springdoc.show-actuator=true diff --git a/src/test/kotlin/com/example/realworld/integration/controller/UserControllerTest.kt b/src/test/kotlin/com/example/realworld/integration/controller/UserControllerTest.kt index e1260e3..eb4c256 100644 --- a/src/test/kotlin/com/example/realworld/integration/controller/UserControllerTest.kt +++ b/src/test/kotlin/com/example/realworld/integration/controller/UserControllerTest.kt @@ -13,8 +13,8 @@ import com.example.realworld.util.builder.user.UserBuilder import com.example.realworld.util.extension.getForEntity import com.example.realworld.util.extension.putForEntity import io.kotest.assertions.asClue +import io.kotest.matchers.equality.FieldsEqualityCheckConfig import io.kotest.matchers.equality.shouldBeEqualToComparingFields -import io.kotest.matchers.equality.shouldBeEqualToComparingFieldsExcept import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNot import io.kotest.matchers.shouldNotBe @@ -85,7 +85,8 @@ class UserControllerTest { // assert response.statusCode shouldBe HttpStatus.OK - actualUser.shouldBeEqualToComparingFieldsExcept(expected, UpdateUserResponseData::token) + actualUser.shouldBeEqualToComparingFields(expected, + FieldsEqualityCheckConfig(propertiesToExclude = listOf(UpdateUserResponseData::token))) actualUser.token.asClue { it shouldNot beEmpty() it shouldNotBe token diff --git a/src/test/kotlin/com/example/realworld/integration/controller/UserControllerForbiddenTest.kt b/src/test/kotlin/com/example/realworld/integration/controller/UserControllerUnauthorizedTest.kt similarity index 84% rename from src/test/kotlin/com/example/realworld/integration/controller/UserControllerForbiddenTest.kt rename to src/test/kotlin/com/example/realworld/integration/controller/UserControllerUnauthorizedTest.kt index de62264..90caa19 100644 --- a/src/test/kotlin/com/example/realworld/integration/controller/UserControllerForbiddenTest.kt +++ b/src/test/kotlin/com/example/realworld/integration/controller/UserControllerUnauthorizedTest.kt @@ -5,7 +5,6 @@ import com.example.realworld.repository.UserRepository import com.example.realworld.service.UserService import com.example.realworld.util.annotation.WebMvcIntegrationTest import com.example.realworld.util.extension.authorizationHeader -import com.example.realworld.util.extension.shouldBeEqualJson import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -18,7 +17,7 @@ import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders @WebMvcIntegrationTest(controllers = [UserControllerTest::class]) -class UserControllerForbiddenTest { +class UserControllerUnauthorizedTest { @Autowired lateinit var mockMvc: MockMvc @@ -49,9 +48,7 @@ class UserControllerForbiddenTest { val response = mockMvc.perform(request).andReturn().response // assert - response.status shouldBe HttpStatus.FORBIDDEN.value() - response.contentType shouldBe MediaType.APPLICATION_JSON_VALUE - response.contentAsString shouldBeEqualJson expected + response.status shouldBe HttpStatus.UNAUTHORIZED.value() } @Test @@ -67,8 +64,6 @@ class UserControllerForbiddenTest { val response = mockMvc.perform(request).andReturn().response // assert - response.status shouldBe HttpStatus.FORBIDDEN.value() - response.contentType shouldBe MediaType.APPLICATION_JSON_VALUE - response.contentAsString shouldBeEqualJson expected + response.status shouldBe HttpStatus.UNAUTHORIZED.value() } } diff --git a/src/test/kotlin/com/example/realworld/integration/controller/UsersControllerTest.kt b/src/test/kotlin/com/example/realworld/integration/controller/UsersControllerTest.kt index c2af15b..901d73a 100644 --- a/src/test/kotlin/com/example/realworld/integration/controller/UsersControllerTest.kt +++ b/src/test/kotlin/com/example/realworld/integration/controller/UsersControllerTest.kt @@ -5,21 +5,16 @@ import com.example.realworld.dto.user.response.CreateUserResponse import com.example.realworld.dto.user.response.CreateUserResponseData import com.example.realworld.util.annotation.SpringBootIntegrationTest import com.example.realworld.util.builder.user.CreateUserRequestDataBuilder -import io.kotest.matchers.equality.shouldBeEqualToComparingFieldsExcept +import io.kotest.matchers.equality.FieldsEqualityCheckConfig +import io.kotest.matchers.equality.shouldBeEqualToComparingFields import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNot import io.kotest.matchers.string.beEmpty import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.boot.test.web.client.postForEntity -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Import -import org.springframework.core.annotation.Order import org.springframework.http.HttpStatus -import org.springframework.web.context.request.RequestContextListener @SpringBootIntegrationTest @@ -48,7 +43,7 @@ class UsersControllerTest { // assert response.statusCode shouldBe HttpStatus.CREATED - actualUser.shouldBeEqualToComparingFieldsExcept(expected, CreateUserResponseData::token) + actualUser.shouldBeEqualToComparingFields(expected, FieldsEqualityCheckConfig(propertiesToExclude = listOf(CreateUserResponseData::token))) actualUser.token.trim() shouldNot beEmpty() } } diff --git a/src/test/kotlin/com/example/realworld/util/FakerPtBr.kt b/src/test/kotlin/com/example/realworld/util/FakerPtBr.kt index c139a4f..3b343f7 100644 --- a/src/test/kotlin/com/example/realworld/util/FakerPtBr.kt +++ b/src/test/kotlin/com/example/realworld/util/FakerPtBr.kt @@ -1,7 +1,7 @@ package com.example.realworld.util -import com.github.javafaker.Faker -import java.util.* +import net.datafaker.Faker +import java.util.Locale class FakerPtBr { companion object { diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 8a216d8..2d12b56 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -5,12 +5,14 @@ spring.jpa.properties.hibernate.show_sql=true spring.jpa.properties.hibernate.format_sql=true # data source -# https://dev.mysql.com/doc/connector-j/8.0/en/connector-j-api-changes.html spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.url=jdbc:tc:mysql:8:///testDb?TC_REUSABLE=true&TC_DAEMON=true spring.datasource.username=test spring.datasource.password=test +# Security +spring.security.strategy=MODE_INHERITABLETHREADLOCAL + # JWT jwt.secret=6GOvb%A6NgXeSHW8niU82psGTTdB3GGkOKVe%5jMwwwB!seNUC jwt.expiration-milliseconds=60000