I'm trying to implement Authentication & Authorization with Spring Boot (2.3.1.RELEASE) WebFlux + Kotlin + Coroutines, I think that I reach a point where I cannot find any useful information about Authorization even in the Spring Security github source code.
If I understand correctly, for the Authentication flow I did the following:
@Bean
fun authenticationWebFilter(reactiveAuthenticationManager: ReactiveAuthenticationManager,
jwtConverter: JWTConverter,
serverAuthenticationSuccessHandler: ServerAuthenticationSuccessHandler): AuthenticationWebFilter {
val authenticationWebFilter = AuthenticationWebFilter(reactiveAuthenticationManager)
authenticationWebFilter.setRequiresAuthenticationMatcher { ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login").matches(it) }
authenticationWebFilter.setServerAuthenticationConverter(jwtConverter)
authenticationWebFilter.setAuthenticationSuccessHandler(serverAuthenticationSuccessHandler)
return authenticationWebFilter
}
The flow is:
- ServerWebExchangeMatcher is executed and check if the client is requesting the login endpoint with the correct HTTP verb.
- If everything is ok, the ServerAuthenticationConverter gets the username and password from the body and creates a unauthenticated UsernamePasswordAuthenticationToken.
- ReactiveAuthenticationManager is called and performs the authentication (looks in db and check passwords).
- If everything goes well the ServerAuthenticationSuccessHandler is called.
My ServerAuthenticationConverter:
import com.kemenu.dark.admin.application.HttpExceptionFactory.badRequest
import com.kemenu.dark.admin.application.login.LoginRequest
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.mono
import org.springframework.core.ResolvableType
import org.springframework.http.MediaType
import org.springframework.http.codec.json.AbstractJackson2Decoder
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.web.server.authentication.ServerAuthenticationConverter
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import javax.validation.Validator
@Component
class JWTConverter(private val jacksonDecoder: AbstractJackson2Decoder,
private val validator: Validator) : ServerAuthenticationConverter {
override fun convert(exchange: ServerWebExchange?): Mono<Authentication> = mono {
val loginRequest = getUsernameAndPassword(exchange!!) ?: throw badRequest()
if (validator.validate(loginRequest).isNotEmpty()) {
throw badRequest()
}
return@mono UsernamePasswordAuthenticationToken(loginRequest.username, loginRequest.password)
}
private suspend fun getUsernameAndPassword(exchange: ServerWebExchange): LoginRequest? {
val dataBuffer = exchange.request.body
val type = ResolvableType.forClass(LoginRequest::class.java)
return jacksonDecoder
.decodeToMono(dataBuffer, type, MediaType.APPLICATION_JSON, mapOf())
.onErrorResume { Mono.empty<LoginRequest>() }
.cast(LoginRequest::class.java)
.awaitFirstOrNull()
}
}
My ReactiveAuthenticationManager:
@Bean
fun reactiveAuthenticationManager(reactiveUserDetailsService: AdminReactiveUserDetailsService,
passwordEncoder: PasswordEncoder): ReactiveAuthenticationManager {
val manager = UserDetailsRepositoryReactiveAuthenticationManager(reactiveUserDetailsService)
manager.setPasswordEncoder(passwordEncoder)
return manager
}
My ServerAuthenticationSuccessHandler:
import com.kemenu.dark.admin.application.HttpExceptionFactory.unauthorized
import com.kemenu.dark.admin.application.security.JWTService
import kotlinx.coroutines.reactor.mono
import org.springframework.security.core.Authentication
import org.springframework.security.core.userdetails.User
import org.springframework.security.web.server.WebFilterExchange
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
@Component
class JWTServerAuthenticationSuccessHandler(private val jwtService: JWTService) : ServerAuthenticationSuccessHandler {
private val FIFTEEN_MIN = 1000 * 60 * 15
private val FOUR_HOURS = 1000 * 60 * 60 * 4
override fun onAuthenticationSuccess(webFilterExchange: WebFilterExchange?, authentication: Authentication?): Mono<Void> = mono {
val principal = authentication?.principal ?: throw unauthorized()
when(principal) {
is User -> {
val accessToken = jwtService.accessToken(principal.username, FIFTEEN_MIN)
val refreshToken = jwtService.refreshToken(principal.username, FOUR_HOURS)
webFilterExchange?.exchange?.response?.headers?.set("Authorization", accessToken)
webFilterExchange?.exchange?.response?.headers?.set("JWT-Refresh-Token", refreshToken)
}
}
return@mono null
}
}
My Security config:
import com.kemenu.dark.admin.application.security.authentication.AdminReactiveUserDetailsService
import com.kemenu.dark.admin.application.security.authentication.JWTConverter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.io.ClassPathResource
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.http.codec.json.AbstractJackson2Decoder
import org.springframework.http.codec.json.Jackson2JsonDecoder
import org.springframework.security.authentication.ReactiveAuthenticationManager
import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager
import org.springframework.security.authorization.ReactiveAuthorizationManager
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.SecurityWebFiltersOrder
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.server.SecurityWebFilterChain
import org.springframework.security.web.server.authentication.AuthenticationWebFilter
import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler
import org.springframework.security.web.server.authorization.AuthorizationWebFilter
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers
import org.springframework.web.reactive.config.EnableWebFlux
import org.springframework.web.reactive.config.WebFluxConfigurer
import org.springframework.web.reactive.function.server.router
import org.springframework.web.server.ServerWebExchange
import java.net.URI
@Configuration
@EnableWebFlux
@EnableWebFluxSecurity
class WebConfig : WebFluxConfigurer {
@Bean
fun configureSecurity(http: ServerHttpSecurity,
jwtAuthenticationFilter: AuthenticationWebFilter,
jwtAuthorizationWebFilter: AuthorizationWebFilter): SecurityWebFilterChain {
return http
.cors().disable()
.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.logout().disable()
.authorizeExchange()
.pathMatchers("/login").permitAll()
.anyExchange().authenticated()
.and()
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.addFilterAt(jwtAuthorizationWebFilter, SecurityWebFiltersOrder.AUTHORIZATION)
.build()
}
@Bean
fun mainRouter() = router {
accept(MediaType.TEXT_HTML).nest {
GET("/") { temporaryRedirect(URI("/index.html")).build() }
}
resources("/**", ClassPathResource("public/"))
}
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
@Bean
fun authenticationWebFilter(reactiveAuthenticationManager: ReactiveAuthenticationManager,
jwtConverter: JWTConverter,
serverAuthenticationSuccessHandler: ServerAuthenticationSuccessHandler): AuthenticationWebFilter {
val authenticationWebFilter = AuthenticationWebFilter(reactiveAuthenticationManager)
authenticationWebFilter.setRequiresAuthenticationMatcher { ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/login").matches(it) }
authenticationWebFilter.setServerAuthenticationConverter(jwtConverter)
authenticationWebFilter.setAuthenticationSuccessHandler(serverAuthenticationSuccessHandler)
return authenticationWebFilter
}
@Bean
fun authorizationWebFilter(jwtReactiveAuthorizationManager: ReactiveAuthorizationManager<ServerWebExchange>): AuthorizationWebFilter = AuthorizationWebFilter(jwtReactiveAuthorizationManager)
@Bean
fun jacksonDecoder(): AbstractJackson2Decoder = Jackson2JsonDecoder()
@Bean
fun reactiveAuthenticationManager(reactiveUserDetailsService: AdminReactiveUserDetailsService,
passwordEncoder: PasswordEncoder): ReactiveAuthenticationManager {
val manager = UserDetailsRepositoryReactiveAuthenticationManager(reactiveUserDetailsService)
manager.setPasswordEncoder(passwordEncoder)
return manager
}
}
Now for the Authorization I created a ReactiveAuthorizationManager:
import com.kemenu.dark.admin.application.security.JWTService
import kotlinx.coroutines.reactor.mono
import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.authorization.AuthorizationDecision
import org.springframework.security.authorization.ReactiveAuthorizationManager
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
@Component
class JWTReactiveAuthorizationManager(private val jwtService: JWTService) : ReactiveAuthorizationManager<ServerWebExchange> {
override fun check(authentication: Mono<Authentication>?, exchange: ServerWebExchange?): Mono<AuthorizationDecision> = mono {
val authHeader = exchange?.request?.headers?.getFirst(HttpHeaders.AUTHORIZATION) ?: return@mono AuthorizationDecision(false)
if (!authHeader.startsWith("Bearer ")) {
return@mono AuthorizationDecision(false)
}
val decodedJWT = jwtService.decodeAccessToken(authHeader)
if (decodedJWT.subject.isNullOrBlank()) {
return@mono AuthorizationDecision(false)
}
SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken(decodedJWT.subject, null, listOf())
return@mono AuthorizationDecision(true)
}
}
And I added it to the configuration as a WebFilter for Authorization.
So far so good, my problem comes when I run this test:
@Test
fun `Given an admin when tries to fetch data from customers API with AUTHORIZATION then receives the data`() {
val customer = CustomerHelper.random()
runBlocking {
customerRepository.save(customer)
}
webTestClient
.get().uri("/v1/customers")
.header(HttpHeaders.AUTHORIZATION, accessToken())
.exchange()
.expectStatus().isOk
.expectBodyList<Customer>()
.contains(customer)
}
Then I receive an unauthorized 401 http error, why?