The issues I encountered when migrating Spring Boot from 2.x to 3.x including Spring Security
In this article I share my exprience in migration of Spring boot 3.x. My application contains login/social login, basic auth and hibernate.
- Upgrade to Java 17. It’s the minimum requirement.
- Update Spring boot to the newest 2.x version
as mentioend in the official guide
https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide
Replace WebSecurityConfigurerAdapter
Create Beans for SecurityFilterChain is not a big issue at this step yet.
Short reference:
https://www.baeldung.com/spring-deprecated-websecurityconfigureradapter
Long:
https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
My biggest issue here is to understand the difference between Global AuthenticationManager and Local AuthenticationManager.
To create an
AuthenticationManager
that is available to the entire application you register theAuthenticationManager
as a@Bean.
Local
AuthenticationManager
means the defaultAuthenticationManager
is overwritten for a specificSecurityFilterChain
.
In my usecase, some endpoints are authenticated by Basic auth and the credentials are set via environment variables. The other endpoints are authenticated by email and password stored in the database. Thus,
At the end, these are the examlpes for Local and Global AuthenticationManager
BasicAuthSecurityConfig.kt
@Configuration
class BasicAuthSecurityConfig(
@Autowired
fun configureGlobal(auth: AuthenticationManagerBuilder) {
auth.inMemoryAuthentication()
.withUser(client.clientId)
.password("{bcrypt}${passwordEncoder.encode(client.clientSecret)}")
.roles(*client.clientRoles!!.map { it.name }.toTypedArray())
}
}
UserRegistrationSecurityConfig.kt
@Configuration
class UserRegistrationSecurityConfig {
@Bean
fun authManager(http: HttpSecurity): AuthenticationManager {
val authenticationManagerBuilder = http.getSharedObject(
AuthenticationManagerBuilder::class.java
)
.userDetailsService(userDetailsService)
.passwordEncoder(encoder)
return authenticationManagerBuilder.build()
}
}
After replacing, I started to test social login and login, and basic auth to make sure this part is fine and then move on.
I must admit that seeing what’s working doesn’t really match the theory about Local/Global AuthenticationManager, but I do see the issue comes from AuthenticationManager, when your basic auth works and the other doesn’t and the other way around.
My approach probably configures the Global AuthenticationManager to work both for basic auth and user login.
Upgrade to 3.x
Simply change it in the package mamangement tool. Then you start to see some red flags. That’s ok. Lets deal with them one by one
Update SecurityFilterChain
This @Order(1) @Order(2) … no longer work for my application. I start to use securityMatcher. I like the explaination in the offical guide very much.
The idea is each SecurityFilterChain is registered for certain paths by “securityMatcher()”
BasicAuthSecurityConfig.kt
@Bean
fun filterChainBasicAuth(http: HttpSecurity): SecurityFilterChain {
http.securityMatcher(
"/admin/**,
)
authorizeHttpRequests {
authz ->
authz
.requestMatchers(
"/admin/users/**",
).hasRole("admin).anyRequest().authenticated()
....
UserRegistrationSecurityConfig.kt
@Bean
fun filterChainBasicAuth(http: HttpSecurity): SecurityFilterChain {
http.securityMatcher(
"/user/**,
)
authorizeHttpRequests {
authz ->
authz
.requestMatchers(
"/user/**",
)....
For sure in your code you will want to replace “/admin” with a const and replace “/user” with a const as well
This step takes me some time to figure out and verify the configuration. Take your time and be patient.
Replace import javax with import jakarta
There are a lot import javax has to be replaced by import jakarta. Use IDE to help this task.
Error: Schema-validation: missing sequence
This is the other issue that needs to read documentation. I recommend to read
Spring Boot 2 uses hibernate 5 and Spring Boot 3 uses hibernate 6. For auto-generated id like the following:
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
val id: Long = 0
Hibernatet 5 relies on one sequence for all tables, and Hibernate 6 requires each table has its own index. You can find this behaviour in your database.
It will be cubersome to handle the sequence issue now, so I decide to set the following in application.properties
spring.jpa.properties.hibernate.id.db_structure_naming_strategy=single
Such that the legacy behaviour is kept.
Swagger does not show up
You can remove all the libs from SpringDoc(My app had three) and only use
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2")
Official guide: https://springdoc.org/v2/#migrating-from-springdoc-v1
Keep the format of the error response body
My application use both @ControllerAdvice and throw ResponseStatusException.
For those managed by @ControllerAdvice, the format doesn’t change. For those error response body generated by ResponseStatusException, the format changes.
The following class adds the missing fields
// Backward-compatible with error response body of Spring Boot 2
@ControllerAdvice
class BackwardCompatibleResponseEntityExceptionHandler : ResponseEntityExceptionHandler() {
override fun createResponseEntity(
@Nullable body: Any?,
headers: HttpHeaders,
statusCode: HttpStatusCode,
request: WebRequest
): ResponseEntity<Any> {
if (body is ProblemDetail) {
val problemDetail = body
problemDetail.setProperty("error", problemDetail.title!!)
problemDetail.setProperty("timestamp", Date())
if (request is ServletWebRequest) {
problemDetail.setProperty(
"path", request.request
.requestURI
)
}
}
return ResponseEntity(body, headers, statusCode)
}
}
Ref: https://stackoverflow.com/questions/75029947/error-response-body-changed-after-boot-3-upgrade
The error response body is broken when using @CotrollerAdvice and ResponseStatusException together.
Example when throwing ResponseStatusException(404, “Resource not found”)`
without @ControllerAdvice :
{
"timestamp": "2023-01-01T01::10.111+00:00",
"status": 404,
"error": "Not Found",
"message": "Resource not found",
"path": "/api/v1/hi"
}
with @ControllerAdvice :
{
"type": "about:blank",
"title": "problemDetail.title.org.springframework.web.server.ResponseStatusException",
"status": 404,
"detail": "problemDetail.org.springframework.web.server.ResponseStatusException",
"instance": "/api/v1/apps/token"
}
@ControllerAdvice forces the error response body of ResponseStatusException to follow the RFC standard introduced in Spring Boot 3.
The source code [ResponseEntityExceptionHandler] doesn’t set ex.exception (ResponseStatusException) to title or detail, and only pick the java class type.
@Nullable
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatusCode statusCode, WebRequest request) {
if (request instanceof ServletWebRequest servletWebRequest) {
HttpServletResponse response = servletWebRequest.getResponse();
if (response != null && response.isCommitted()) {
if (logger.isWarnEnabled()) {
logger.warn("Response already committed. Ignoring: " + ex);
}
return null;
}
}
if (statusCode.equals(HttpStatus.INTERNAL_SERVER_ERROR)) {
request.setAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE, ex, WebRequest.SCOPE_REQUEST);
}
// See here, ex(ResponseStatusException) is not used. "body" doesn't know anything about ex
if (body == null && ex instanceof ErrorResponse errorResponse) {
body = errorResponse.updateAndGetBody(this.messageSource, LocaleContextHolder.getLocale());
}
return createResponseEntity(body, headers, statusCode, request);
}
In the future: I would recommend to only use @ControllerAdvice instead of ResponseStatusException. For migration phase, I recommend to wrap ResponseStatusException and let it return a cusom ErrorResponse and not replace all places that are using ResponseStatusException.