I'm in the process of migrating my Kotlin API to Springboot version 3.
I'm having a problem where certain endpoints aren't getting hit, for example::
a GET to localhost:8081/search/hello
works just fine, here is the GET endpoint::
#PreAuthorize("hasRole('ROLE_MYROLE')")
#GetMapping("/search/{somephrase}")
suspend fun getSomething(#PathVariable("somephrase") phrase: String): someResponse {
return myService.getPhrase(phrase)
}
a PATCH to localhost:8081/update/resourceId
does not, and here is my patch endpoint::
#PreAuthorize("hasRole('ROLE_MYROLE')")
#PatchMapping("/update/{resourceId}")
suspend fun updateSomething(
#PathVariable("resourceId") resourceId: Long,
#RequestBody updateJson: JsonNode
): UpdateResponse {
return recommendationService.update(updateJson, resourceId)
}
Here is the reponse I get::
{
"timestamp": "2023-02-12 09:59:50",
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/search"
}
and this in the console::
: [preHandle] PATCH /error - operationId: 00000000000000000000000000000000 |
Here is my Spring Security Config::
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.DefaultSecurityFilterChain
#Configuration
#EnableWebSecurity
class ResourceServerConfiguration {
#Bean
fun configure(http: HttpSecurity): DefaultSecurityFilterChain? {
http.authorizeHttpRequests()
.requestMatchers("/csrf").permitAll()
.requestMatchers("/**").authenticated()
.and()
.csrf()
.disable()
.oauth2ResourceServer().jwt().jwtAuthenticationConverter { AuthAwareTokenConverter().convert(it) }
return http.build()
}
}
and here is my token converter (it just adds a couple of extra claims to the token)
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter
class AuthAwareTokenConverter : Converter<Jwt, AbstractAuthenticationToken> {
private val jwtGrantedAuthoritiesConverter: Converter<Jwt, Collection<GrantedAuthority>> =
JwtGrantedAuthoritiesConverter()
override fun convert(jwt: Jwt): AbstractAuthenticationToken {
val claims = jwt.claims
val principal = findPrincipal(claims)
val authorities = extractAuthorities(jwt)
return AuthAwareAuthenticationToken(jwt, principal, authorities)
}
private fun findPrincipal(claims: Map<String, Any?>): String {
return if (claims.containsKey(CLAIM_USERNAME)) {
claims[CLAIM_USERNAME] as String
} else if (claims.containsKey(CLAIM_USER_ID)) {
claims[CLAIM_USER_ID] as String
} else {
claims[CLAIM_CLIENT_ID] as String
}
}
private fun extractAuthorities(jwt: Jwt): Collection<GrantedAuthority> {
val authorities = mutableListOf<GrantedAuthority>().apply { addAll(jwtGrantedAuthoritiesConverter.convert(jwt)!!) }
if (jwt.claims.containsKey(CLAIM_AUTHORITY)) {
#Suppress("UNCHECKED_CAST")
val claimAuthorities = (jwt.claims[CLAIM_AUTHORITY] as Collection<String>).toList()
authorities.addAll(claimAuthorities.map(::SimpleGrantedAuthority))
}
return authorities.toSet()
}
companion object {
const val CLAIM_USERNAME = "user_name"
const val CLAIM_USER_ID = "user_id"
const val CLAIM_CLIENT_ID = "client_id"
const val CLAIM_AUTHORITY = "authorities"
}
}
class AuthAwareAuthenticationToken(
jwt: Jwt,
private val aPrincipal: String,
authorities: Collection<GrantedAuthority>
) : JwtAuthenticationToken(jwt, authorities) {
override fun getPrincipal(): String {
return aPrincipal
}
}
What I've tried::
Simplifying the PATCH endpoint to simply return a "hello" String to rule out an issue with the service.
The result is the same, so I think the point of failure must be my Spring Security configuration.
Any help/pointers would be much appreciated!
Interesting! I appear to have found the solution.
I added an extra forward slash to the end of the path in my mapping.
So I changed this::
#PatchMapping("/update/{resourceId}")
to this::
#PatchMapping("/update/{resourceId}/")
and it works!
Oddly SpringBoot version 2.X worked just fine with or without the forward slash. But SpringBoot version 3.X requires it.
Related
Apologies for the lengthy post. I have a spring boot application which contains both rest and graphql APIs. Also I'm using keycloak auth server. I want to add keycloak authentication for both apis. Setup is as follows
Dependencies
I have added following dependencies as mentioned in docs.
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-spring-security-adapter</artifactId>
<version>13.0.1</version>
</dependency>
//-- other dependencies
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak.bom</groupId>
<artifactId>keycloak-adapter-bom</artifactId>
<version>13.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Additionally I'm using following dependencies for spring security and expediagroup graphql kotlin support.
<dependency>
<groupId>com.expediagroup</groupId>
<artifactId>graphql-kotlin-spring-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Security Configuration
import org.keycloak.adapters.KeycloakConfigResolver
import org.keycloak.adapters.springboot.KeycloakSpringBootConfigResolver
import org.keycloak.adapters.springsecurity.KeycloakConfiguration
import org.keycloak.adapters.springsecurity.KeycloakSecurityComponents
import org.keycloak.adapters.springsecurity.client.KeycloakClientRequestFactory
import org.keycloak.adapters.springsecurity.client.KeycloakRestTemplate
import org.keycloak.adapters.springsecurity.config.KeycloakWebSecurityConfigurerAdapter
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.config.ConfigurableBeanFactory
import org.springframework.context.annotation.*
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper
import org.springframework.security.core.session.SessionRegistryImpl
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy
#KeycloakConfiguration
#EnableWebSecurity
#EnableGlobalMethodSecurity(jsr250Enabled = true)
#ComponentScan(basePackageClasses = [KeycloakSecurityComponents::class])
class KeycloakSecurityConfig: KeycloakWebSecurityConfigurerAdapter() {
#Autowired
var keycloakClientRequestFactory: KeycloakClientRequestFactory? = null
#Throws(Exception::class)
override fun configure(http: HttpSecurity) {
super.configure(http)
http.csrf().disable().cors().disable()
.authorizeRequests()
.anyRequest()
.permitAll()
}
#Autowired
#Throws(Exception::class)
fun configureGlobal(auth: AuthenticationManagerBuilder) {
val keycloakAuthenticationProvider = keycloakAuthenticationProvider()
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(SimpleAuthorityMapper())
auth.authenticationProvider(keycloakAuthenticationProvider)
}
#Bean
override fun sessionAuthenticationStrategy(): SessionAuthenticationStrategy? {
return RegisterSessionAuthenticationStrategy(SessionRegistryImpl())
}
#Bean
fun KeycloakConfigResolver(): KeycloakConfigResolver? {
return KeycloakSpringBootConfigResolver()
}
#Bean
#Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
fun keycloakRestTemplate(): KeycloakRestTemplate? {
return KeycloakRestTemplate(keycloakClientRequestFactory)
}
}
Endpoints
These are my sample rest and graphql endpoints.
GraphQL
import com.expediagroup.graphql.spring.operations.Query
import org.springframework.stereotype.Component
import reactor.core.publisher.Mono
import java.util.concurrent.CompletableFuture
#Component
class HelloQuery: Query {
fun helloWorld():CompletableFuture<String>{
return Mono.just("hello from graphql").toFuture()
}
}
Rest Controller
import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.ResponseBody
#Controller
class HelloController {
#GetMapping("/hello")
#ResponseBody
fun hello(): String {
return "hello from rest"
}
}
Results
When I run the application and test the APIs get following results.
Rest API - work successfully
GraphQL - 404 error
But when I remove all security configs graphql api also work successfully.
My problem is can I use graphql with keycloak spring security. If yes what am I doing wrong here?
Any help is appreciated.
Needed to do some changes to security config file.
adding corsFilter
adding securityWebFilterChain
Also I'm using dependency
<dependency>
<groupId>com.expediagroup</groupId>
<artifactId>graphql-kotlin-spring-server</artifactId>
<version>${graphql-kotlin-spring-server.version}</version>
</dependency>
It worked for me. Posting the answer hopefully will help someone.
Security config file
#EnableWebFluxSecurity
#Configuration
internal class SecurityConfig(
private val monitoringProperties: MonitoringProperties,
private val securityProperties: SecurityProperties,
private val graphQLConfigurationProperties: GraphQLConfigurationProperties
) {
companion object {
const val AUTHORITY_ACTUATOR = "AUTHORITY_ACTUATOR"
const val AUTHORITY_ACCESS_MONITORING = "AUTHORITY_ACCESS_MONITORING"
const val AUTHORITY_ACCESS_INTERNAL_API = "AUTHORITY_ACCESS_INTERNAL_API"
}
#Bean
#ConditionalOnProperty(value = ["spring.security.user.passwordGenerated"], matchIfMissing = true, havingValue = "false")
fun userDetailsService(): MapReactiveUserDetailsService {
val actuatorUser = User
.withUsername(securityProperties.user.name)
.password("{noop}${securityProperties.user.password}")
.authorities(AUTHORITY_ACTUATOR, AUTHORITY_ACCESS_MONITORING, AUTHORITY_ACCESS_INTERNAL_API).build()
val monitoringUser = User
.withUsername(monitoringProperties.username)
.password("{noop}${monitoringProperties.password}")
.authorities(AUTHORITY_ACCESS_MONITORING)
.build()
return MapReactiveUserDetailsService(actuatorUser, monitoringUser)
}
#Bean
fun corsWebFilter(): CorsWebFilter {
return CorsWebFilter(UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration(
"/${graphQLConfigurationProperties.endpoint}",
CorsConfiguration().apply {
allowCredentials = true
allowedOrigins = listOf("*")
allowedHeaders = listOf("*")
allowedMethods = listOf("*")
}
)
registerCorsConfiguration(
"/${graphQLConfigurationProperties.subscriptions.endpoint}",
CorsConfiguration().apply {
allowCredentials = true
allowedOrigins = listOf("*")
allowedHeaders = listOf("*")
allowedMethods = listOf("*")
}
)
registerCorsConfiguration(
"/web/*",
CorsConfiguration().apply {
allowCredentials = true
allowedOrigins = listOf("*")
allowedHeaders = listOf("*")
allowedMethods = listOf("*")
}
)
})
}
#Bean
fun securityWebFilterChain(
http: ServerHttpSecurity
): SecurityWebFilterChain {
return http {
httpBasic { }
configureKeycloakAuth()
csrf { disable() }
authorizeExchange {
// actuator and monitoring
authorize(EndpointRequest.to("health", "info"), permitAll)
authorize(EndpointRequest.to(PrometheusScrapeEndpoint::class.java), hasAnyAuthority(AUTHORITY_ACCESS_MONITORING))
authorize(EndpointRequest.toAnyEndpoint(), hasAuthority(AUTHORITY_ACTUATOR))
authorizeGraphQL()
// rest
authorize(anyExchange, denyAll)
}
requestCache { disable() }
}
}
private fun ServerHttpSecurityDsl.configureKeycloakAuth() {
oauth2ResourceServer {
bearerTokenConverter = ServerBearerTokenAuthenticationConverter().apply { setAllowUriQueryParameter(true) }
jwt { jwtAuthenticationConverter = KeycloakJwtAuthenticationConverter() }
}
}
private fun AuthorizeExchangeDsl.authorizeGraphQL() {
authorize(ServerWebExchangeMatchers.pathMatchers(HttpMethod.OPTIONS, "/${graphQLConfigurationProperties.endpoint}"), permitAll)
authorize(antPattern = "/${graphQLConfigurationProperties.endpoint}", access = hasAnyAuthority("ADMIN", "USER"))
authorize(antPattern = "/${graphQLConfigurationProperties.subscriptions.endpoint}", access = hasAnyAuthority("ADMIN"))
authorize(antPattern = "/web/*", access = permitAll)
if (graphQLConfigurationProperties.playground.enabled) {
authorize(antPattern = "/${graphQLConfigurationProperties.playground.endpoint}", access = permitAll)
}
if (graphQLConfigurationProperties.sdl.enabled) {
authorize(antPattern = "/${graphQLConfigurationProperties.sdl.endpoint}", access = permitAll)
}
}
}
class KeycloakJwtAuthenticationConverter : Converter<Jwt, Mono<AbstractAuthenticationToken>> {
private val delegate = JwtAuthenticationConverter()
override fun convert(jwt: Jwt): Mono<AbstractAuthenticationToken> {
return Mono.just(jwt)
.map { source: Jwt -> delegate.convert(source) }
.map { auth ->
when (auth) {
is JwtAuthenticationToken -> try {
KeycloakToken.fromOauth2Token(auth)
} catch (e: Exception) {
auth
}
else -> auth
}
}
}
}
class KeycloakToken(authorities: Collection<out GrantedAuthority>, private val principal: AuthenticatedUser, private val credentials: Any) :
AbstractAuthenticationToken(authorities) {
companion object {
fun fromOauth2Token(oauth2Token: JwtAuthenticationToken): KeycloakToken {
val id = UUID.fromString(oauth2Token.name)
val jwt = oauth2Token.principal as Jwt
val email = jwt.claims["email"] as? String
val firstName = jwt.claims["given_name"] as? String
val lastName = jwt.claims["family_name"] as? String
val fullName = jwt.claims["name"] as? String
if (email == null || firstName == null || lastName == null || fullName == null) {
throw RuntimeException("User Details incomplete - name not set of user with id $id: email: $email, first name: $firstName, last name: $lastName, full name: $fullName")
}
val realmAccess = jwt.claims["realm_access"] as Map<*, *>
val roles = (realmAccess["roles"] as List<*>).map { it.toString() }
val authorities = oauth2Token.authorities.toMutableList()
authorities.addAll(roles.map { SimpleGrantedAuthority(it) })
return KeycloakToken(
authorities = authorities,
principal = AuthenticatedUser(
id = id,
email = email,
firstName = firstName,
lastName = lastName,
fullName = fullName,
roles = roles.toList()
),
credentials = oauth2Token.credentials
)
.apply {
details = oauth2Token
isAuthenticated = true
}
}
}
override fun getPrincipal() = principal
override fun getCredentials() = credentials
}
Spring Cloud Gateway as a OAuth2ResourceServer with following Authorisation Config:
#Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.authorizeExchange(exchanges ->
exchanges
.anyExchange().authenticated()
)
.oauth2ResourceServer(OAuth2ResourceServerSpec::jwt)
return http.build();
}
I have a global filter in place which is responsible for performing some functions at each valid authenticated request, something like this:
#Service
public class CustomGlobal implements GlobalFilter {
#Autowired
BearerTokenAuthentication authentication;
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// access request headers, and perform some logic
// extract details from the JWT token, and perform some logic
log.info(authentication.getTokenAttributes.get("sub"));
// ^ in the above line there's a NullPointerException, since instance
// BearerTokenAuthentication is not set, or not visible at a GlobalFilter class
return chain.filter(exchange);
}
}
I am still in a learning phase. Any possible leads would be appreciated.
I did that this way(Note you should change WebFilter to GlobalFilter).
Add into your pom
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
<version>5.4.6</version>
</dependency>
Then Filter should be like
package filter;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
#Log4j2
public class CustomGlobal implements WebFilter {
public static final String HEADER_PREFIX = "Bearer ";
private final ReactiveJwtDecoder jwtDecoder;
public ReactiveJwtDecoder createDecoder(String issuer, String jwkUrl) {
var jwtDecoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwkUrl).build();
jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
new JwtIssuerValidator(issuer),
new JwtTimestampValidator()));
return jwtDecoder;
}
protected CustomGlobal(String issuer, String jwkUrl) {
this.jwtDecoder = createDecoder(issuer, jwkUrl);
}
#Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return Mono
.defer(() -> {
var token = resolveToken(exchange.getRequest());
if (!StringUtils.hasText(token)) {
throw new BadJwtException("Authorisation token is invalid");
}
return jwtDecoder.decode(token);
})
.flatMap(tokenJwt -> {
log.info(tokenJwt.getClaimAsString("sub"));
return chain.filter(exchange);
})
.onErrorResume(err -> handleError(exchange));
}
private Mono<Void> handleError(ServerWebExchange exchange) {
exchange.getResponse().setRawStatusCode(HttpStatus.UNAUTHORIZED.value());
exchange.getResponse().getHeaders().add("Content-Type", "application/json");
return exchange.getResponse().setComplete();
}
private String resolveToken(ServerHttpRequest request) {
String bearerToken = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(HEADER_PREFIX)) {
return bearerToken.substring(7).trim();
}
return "";
}
}
Next step would be to create configuration
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
#Configuration
public class CustomGlobalConfig {
#Value("${jwt.iss}")
private String issuer;
#Value("${jwt.jwk-uri}")
private String jwkUrl;
#Bean
CustomGlobal createFilterBean() {
return new CustomGlobal(this.issuer, this.jwkUrl);
}
}
When I fire refreshtoken api on postman I got this type of error:
java.lang.IllegalStateException: No primary or single public
constructor found for interface javax.servlet.http.HttpServletRequest
and no default constructor found either
Here all the details of my filterclass and controller
jwtrefresh.kt:
package com.main.jwtrefresh.filter
import com.main.jwtrefresh.service.UuserService
import com.main.jwtrefresh.util.JwtRefreshTokenUtil
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import java.io.IOException
import javax.servlet.FilterChain
import javax.servlet.ServletException
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import io.jsonwebtoken.ExpiredJwtException
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.core.userdetails.User
import org.springframework.util.StringUtils
#Component
class JwtRefreshFilterUser : OncePerRequestFilter() {
#Autowired
private lateinit var jwtRefreshTokenUtil: JwtRefreshTokenUtil
#Autowired
private lateinit var uuserService: UuserService
#Throws(ServletException::class, IOException::class)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
try {
val jwtToken: String? = extractJwtFromRequest(request)
if (StringUtils.hasText(jwtToken) && jwtRefreshTokenUtil.validateToken(jwtToken)) {
val userDetails: UserDetails = User(jwtRefreshTokenUtil.getUsernameFromToken(jwtToken), "",
ArrayList())
val usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.authorities
)
SecurityContextHolder.getContext().authentication = usernamePasswordAuthenticationToken
} else {
println("Cannot set the Security Context")
}
} catch (ex: ExpiredJwtException) {
val isRefreshToken = request.getHeader("isRefreshToken")
val requestURL = request.requestURL.toString()
if (isRefreshToken != null && isRefreshToken == "true" && requestURL.contains("refreshToken")) {
allowRefreshToken(ex, request)
} else
request.setAttribute("Exception", ex)
} catch (ex: BadCredentialsException) {
request.setAttribute("Exception", ex)
} catch (ex: Exception) {
println(ex)
}
filterChain.doFilter(request, response)
}
private fun allowRefreshToken(ex: ExpiredJwtException, request: HttpServletRequest) {
val usernamePasswordAuthenticationToken = UsernamePasswordAuthenticationToken(null, null, null)
SecurityContextHolder.getContext().authentication = usernamePasswordAuthenticationToken
request.setAttribute("claims", ex.claims)
}
private fun extractJwtFromRequest(request: HttpServletRequest): String? {
val bearerToken: String = request.getHeader("Authorization")
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
bearerToken.substring(7, bearerToken.length)
}
return null
}
}
jwtcontroller.kt:
#PostMapping("/refreshToken")
#Throws(Exception::class)
fun refreshToken(#ModelAttribute request: HttpServletRequest): ResponseEntity<*>? {
val claims = request.getAttribute("claims") as DefaultClaims
val expectedMap = getMapFromIoJsonwebtokenClaims(claims)
val token: String = jwtRefreshTokenUtil.doGenerateRefreshToken(expectedMap, expectedMap["sub"].toString())
return ResponseEntity(ResToken(token),HttpStatus.OK)
}
private fun getMapFromIoJsonwebtokenClaims(claims: DefaultClaims): Map<String, Any> {
val expectedMap: MutableMap<String, Any> = HashMap()
for ((key, value) in claims) {
expectedMap[key] = value
}
return expectedMap
}
I am working on a simple project which uses Spring Boot 2 with Spring WebFlux using Kotlin.
I wrote test for my handler function (in which I mock the dependencies using Mockito).
However, it seems like my route function does not trigger the handler, as all of my requests return HTTP 404 NOT FOUND (even though the route is correct).
I have looked at various other projects to find out what how these tests are supposed to be written (here, here), but the problem persists.
The code is as follows (and can also be found on GitHub):
UserRouterTest
#ExtendWith(SpringExtension::class, MockitoExtension::class)
#Import(UserHandler::class)
#WebFluxTest
class UserRouterTest {
#MockBean
private lateinit var userService: UserService
#Autowired
private lateinit var userHandler: UserHandler
#Test
fun givenExistingCustomer_whenGetCustomerByID_thenCustomerFound() {
val expectedCustomer = User("test", "test")
val id = expectedCustomer.userID
`when`(userService.getUserByID(id)).thenReturn(Optional.ofNullable(expectedCustomer))
val router = UserRouter().userRoutes(userHandler)
val client = WebTestClient.bindToRouterFunction(router).build()
client.get()
.uri("/users/$id")
.accept(MediaType.ALL)
.exchange()
.expectStatus().isOk
.expectHeader().contentType(MediaType.APPLICATION_JSON_UTF8)
.expectBody(User::class.java)
}
}
User
#Entity
class User(var username : String, var password: String) {
#Id
val userID = UUID.randomUUID()
}
UserRepository
#Repository
interface UserRepository : JpaRepository<User, UUID>{
}
UserService
#Service
class UserService(
private val userRepository: UserRepository
) {
fun getUserByID(id: UUID): Optional<User> {
return Optional.of(
try {
userRepository.getOne(id)
} catch (e: EntityNotFoundException) {
User("test", "test")
}
)
}
fun addUser(user: User) {
userRepository.save(user)
}
}
UserHandler
#Component
class UserHandler(
private val userService: UserService
) {
fun getUserWithID(request: ServerRequest): Mono<ServerResponse> {
val id = try {
UUID.fromString(request.pathVariable("userID"))
} catch (e: IllegalArgumentException) {
return ServerResponse.badRequest().syncBody("Invalid user id")
}
val user = userService.getUserByID(id).get()
return ServerResponse.ok().contentType(MediaType.APPLICATION_JSON_UTF8)
.body(BodyInserters.fromObject(user))
}
}
UserRouter
#Configuration
class UserRouter {
#Bean
fun userRoutes(userHandler: UserHandler) = router {
contentType(MediaType.APPLICATION_JSON_UTF8).nest {
GET("/users/{userID}", userHandler::getUserWithID)
GET("") { ServerResponse.ok().build() }
}
}
}
EDIT
To route based on the presence of one or more query parameter (regardless of their values), we can do the following:
UserRouter
#Configuration
class UserRouter {
#Bean
fun userRoutes(userHandler: UserHandler) = router {
GET("/users/{userID}", userHandler::getUserWithID)
(GET("/users/")
and queryParam("username") { true }
and queryParam("password") { true }
)
.invoke(userHandler::getUsers)
}
}
Note that GET("/users/?username={username}", userHandler::getUsersWithUsername) does not work.
The way the router is configured - contentType(MediaType.APPLICATION_JSON_UTF8).nest - will only match requests that have this content type, so you would have to either remove the contentType prerequisite or change the test to include it
client.get()
.uri("/users/$id")
.accept(MediaType.ALL)
.header("Content-Type", "application/json;charset=UTF-8")
.exchange()
.expectStatus().isOk
.expectHeader().contentType(MediaType.APPLICATION_JSON_UTF8)
.expectBody(User::class.java)
I'm having a custom authentication scheme. I'm having a REST endpoint that has userId in http uri path and token in http header. I would like to check that such request is perform by valid user with valid token. Users and tokens are stored in mongo collection.
I don't know in which class I should authorize user.
My SecurityConfig:
#EnableWebFluxSecurity
class SecurityConfig {
#Bean
fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
val build = http
.httpBasic().disable()
.formLogin().disable()
.csrf().disable()
.logout().disable()
.authenticationManager(CustomReactiveAuthenticationManager())
.securityContextRepository(CustomServerSecurityContextRepository())
.authorizeExchange().pathMatchers("/api/measurement/**").hasAuthority("ROLE_USER")
.anyExchange().permitAll().and()
return build.build()
}
#Bean
fun userDetailsService(): MapReactiveUserDetailsService {
val user = User.withDefaultPasswordEncoder()
.username("sampleDeviceIdV1")
.password("foo")
.roles("USER")
.build()
return MapReactiveUserDetailsService(user)
}
}
My ServerSecurityContextRepository:
class CustomServerSecurityContextRepository : ServerSecurityContextRepository {
override fun load(exchange: ServerWebExchange): Mono<SecurityContext> {
val authHeader = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
val path = exchange.request.uri.path
return if (path.startsWith("/api/measurement/") && authHeader != null && authHeader.startsWith(prefix = "Bearer ")) {
val deviceId = path.drop(17)
val authToken = authHeader.drop(7)
val auth = UsernamePasswordAuthenticationToken(deviceId, authToken)
Mono.just(SecurityContextImpl(auth))
} else {
Mono.empty()
}
}
override fun save(exchange: ServerWebExchange?, context: SecurityContext?): Mono<Void> {
return Mono.empty()
}
}
Two questions arise:
Is ServerSecurityContextRepository good place to obtain username and token from exchange - or there is a better place to do it?
Where should I perform authentication (check token and username against mongo collection)?
My custom AuthenticationManager does not get called anywhere. Should I do everything inside ServerSecurityContextRepository or perform user and token validation inside ReactiveAuthenticationManager? Or maybe other class would be even more suitable?
It turns out that some tutorials on the web are plain wrong.
I've managed to configure everything using following code:
class DeviceAuthenticationConverter : Function<ServerWebExchange, Mono<Authentication>> {
override fun apply(exchange: ServerWebExchange): Mono<Authentication> {
val authHeader: String? = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
val path: String? = exchange.request.uri.path
return when {
isValidPath(path) && isValidHeader(authHeader) -> Mono.just(UsernamePasswordAuthenticationToken(path?.drop(17), authHeader?.drop(7)))
else -> Mono.empty()
}
}
private fun isValidPath(path: String?) = path != null && path.startsWith(API_MEASUREMENT)
private fun isValidHeader(authHeader: String?) = authHeader != null && authHeader.startsWith(prefix = "Bearer ")
}
And config:
#EnableWebFluxSecurity
class SecurityConfig {
companion object {
const val API_MEASUREMENT = "/api/measurement/"
const val API_MEASUREMENT_PATH = "$API_MEASUREMENT**"
const val DEVICE = "DEVICE"
const val DEVICE_ID = "deviceId"
}
#Bean
fun securityWebFilterChain(http: ServerHttpSecurity, authenticationManager: ReactiveAuthenticationManager) =
http
.httpBasic().disable()
.formLogin().disable()
.csrf().disable()
.logout().disable()
.authorizeExchange().pathMatchers(API_MEASUREMENT_PATH).hasRole(DEVICE)
.anyExchange().permitAll().and().addFilterAt(authenticationWebFilter(authenticationManager), AUTHENTICATION).build()
#Bean
fun userDetailsService(tokenRepository: TokenRepository) = MongoDeviceTokenReactiveUserDetailsService(tokenRepository)
#Bean
fun tokenRepository(template: ReactiveMongoTemplate, passwordEncoder: PasswordEncoder) = MongoTokenRepository(template, passwordEncoder)
#Bean
fun tokenFacade(tokenRepository: TokenRepository) = TokenFacade(tokenRepository)
#Bean
fun authManager(userDetailsService: ReactiveUserDetailsService) = UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService)
private fun authenticationWebFilter(reactiveAuthenticationManager: ReactiveAuthenticationManager) =
AuthenticationWebFilter(reactiveAuthenticationManager).apply {
setAuthenticationConverter(DeviceAuthenticationConverter())
setRequiresAuthenticationMatcher(
ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, API_MEASUREMENT_PATH)
)
}
#Bean
fun passwordEncoder() = PasswordEncoderFactories.createDelegatingPasswordEncoder()
}