JWT Authorization Kotlin Coroutines Spring Security and Spring WebFlux

Viewed 1402

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:

  1. ServerWebExchangeMatcher is executed and check if the client is requesting the login endpoint with the correct HTTP verb.
  2. If everything is ok, the ServerAuthenticationConverter gets the username and password from the body and creates a unauthenticated UsernamePasswordAuthenticationToken.
  3. ReactiveAuthenticationManager is called and performs the authentication (looks in db and check passwords).
  4. 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?

0 Answers
Related