Custom authentication with spring-security and reactive spring - spring-boot

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()
}

Related

Spring Security Google OAuth2 authorizationUri became domain after deployed on aws

So I am current testing with spring securtiy with google oauth.
it works fine, when trying to login with google through
/oauth2/authorization/google
on localhost
but when i deployed the application on tomcat on aws ec2 instance,
the authorizationuri became sometime like below
when running on localhost:
https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount? ............
when running on aws ec2 domain:
http://{domain}:{port}/o/oauth2/v2/auth? ..........
but when i manually replace the http domain and port with https://accounts.google.com/
it directs me to google login page and are able to complete the login successfully.
So i wonder if there is any part of the setting ive being missing or did wrong that cause it to happen.
Thanks in advance.
the complete code for spring security setting is like below
#Configuration
#EnableWebSecurity
class SecurityConfig : WebSecurityConfigurerAdapter() {
#Autowired
private lateinit var oidUserSer:OidUserService
#Bean
fun clientRegistrationRepository(): ClientRegistrationRepository {
return InMemoryClientRegistrationRepository(googleClientRegistration())
}
private fun googleClientRegistration(): ClientRegistration {
return ClientRegistration.withRegistrationId("google")
.clientId("clientId")
.clientSecret("secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://{domain:port}/login/oauth2/code/{registrationId}")
.scope("openid", "profile", "email", "address", "phone")
.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
.tokenUri("https://www.googleapis.com/oauth2/v4/token")
.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
.userNameAttributeName(IdTokenClaimNames.SUB)
.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
.clientName("Google")
.build()
}
#Bean
fun customAuthorizationRequestResolver(): CustomAuthorizationRequestResolver {
val repo = InMemoryClientRegistrationRepository(
CommonOAuth2Provider.GOOGLE.getBuilder("google")
.clientName("Google")
.clientId("61770666483-dj64uabnia7tq2g0kri2ajrb7sl21r3t.apps.googleusercontent.com")
.clientSecret("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")
.redirectUri("https://{domain:port}/login/oauth2/code/{registrationId}")
.build()
)
val baseUri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI
val customizeAuthorizationRequest= CustomAuthorizationRequestResolver(repo,baseUri)
return customizeAuthorizationRequest
}
/**
* セキュリティの有効範囲設定
*/
#Override
override fun configure(web: WebSecurity) {
// org.springframework.security.web.firewall.RequestRejectedException:
// The request was rejected because the URL contained a potentially malicious String ";"
// というエラーログがコンソールに出力されるため、下記を追加
val firewall = DefaultHttpFirewall()
web.httpFirewall(firewall)
web.ignoring().antMatchers(
"/img/**",
"/css/**",
"/js/**",
"/libs/**"
)
}
#Override
#Throws(Exception::class)
override fun configure(http: HttpSecurity) {
http.exceptionHandling()
.authenticationEntryPoint(LoginUrlAuthenticationEntryPoint("/login"));
http.oauth2Login()
.loginPage("/login")
.defaultSuccessUrl("/login-success", true)
.userInfoEndpoint().oidcUserService(oidUserSer).
and()
.failureUrl("/login?error")
.authorizationEndpoint()
.authorizationRequestResolver(customAuthorizationRequestResolver())
http.logout()
//.logoutRequestMatcher(AntPathRequestMatcher("/logout**"))
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.logoutSuccessHandler(CustomLogoutSuccessHandler())
//.invalidateHttpSession(true)
//セッション設定
http.sessionManagement().
invalidSessionUrl("/login?timeout=true")
}
}
class CustomAuthorizationRequestResolver(repo: ClientRegistrationRepository?,
authorizationRequestBaseUri: String?):OAuth2AuthorizationRequestResolver{
private var defaultResolver:DefaultOAuth2AuthorizationRequestResolver? = DefaultOAuth2AuthorizationRequestResolver(repo, authorizationRequestBaseUri)
override fun resolve(request: HttpServletRequest): OAuth2AuthorizationRequest? {
val authorizationRequest:OAuth2AuthorizationRequest? =
this.defaultResolver?.resolve(request)
return customAuthorizationRequest(authorizationRequest)
}
override fun resolve(request: HttpServletRequest?, clientRegistrationId: String?): OAuth2AuthorizationRequest? {
val authorizationRequest = this.defaultResolver?.resolve(
request, clientRegistrationId);
return authorizationRequest?.let { customAuthorizationRequest(it) }
}
private fun customAuthorizationRequest(authorizationRequest: OAuth2AuthorizationRequest?): OAuth2AuthorizationRequest? {
var param:OAuth2AuthorizationRequest? = null
if(authorizationRequest!=null){
val additionalParameter: LinkedHashMap<String,Any> = LinkedHashMap(authorizationRequest!!.additionalParameters)
additionalParameter.put("prompt","select_account+consent")
param = OAuth2AuthorizationRequest.from(authorizationRequest).additionalParameters(additionalParameter).build()
}
return param
}
}

Cannot access to Main Page after using spring-security, although login is successful

I want to add security part to the project and I am using spring security for providing backend security. When I added custom login filter that extends AbstractAuthenticationProcessingFilter of spring security, I got an error about cross origin problem. Now I added http.cors(); to the WebSecurityConfig and I do not get cross origin errors anymore.
I am sending a request to the backend http://localhost:8081/user/sys-role/verifyTargetUrl. Now, the exact error is Uncaught (in promise) Error: Infinite redirect in navigation guard at eval (vue-router.esm-bundler.js?6c02:2913). So somehow frontend vue-router guards find itself in an infinite loop. I will appreciate any of your help.
UPDATE:
It turned out that I don't get the response code as 200 and that causes the infinite loop in vue-router. My question becomes pure spring-security question because there seems to be no issue with vue-router. I send a post request to http://localhost:8081/user/sys-role/verifyTargetUrl but my request does not enter to the PostMapping in backend. It rather enters CustomAuthenticationEntryPoint shown below and sets the code to 504. But in verifyTargetUrl of backend I set it to 200. Besides, onAuthenticationSuccess of CustomAuthenticationSuccessfulHandler is also called in the backend.
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
#Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
Message msg=new Message();
msg.setCode(504);
msg.setMsg("authenticate fail");
httpServletResponse.setStatus(HttpServletResponse.SC_OK);
httpServletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE);
httpServletResponse.setCharacterEncoding(StandardCharsets.UTF_8.toString());
httpServletResponse.getWriter().write(JSON.toJSONString(msg));
}
}
The console of the browser:
config: {url: "http://localhost:8081/user/sys-role/verifyTargetUrl", method: "post", data: "{"userId":1017,"targetUrl":"/Main"}", headers: {…}, transformRequest: Array(1), …} data: {code: 504, msg: "authenticate fail"}
UPDATE 2: More Code
CustomJSONLoginFilter.java
public class CustomJSONLoginFilter extends AbstractAuthenticationProcessingFilter {
private final ISysUserService iUserService;
public CustomJSONLoginFilter(String defaultFilterProcessesUrl, ISysUserService iUserService) {
super(new AntPathRequestMatcher(defaultFilterProcessesUrl, HttpMethod.POST.name()));
this.iUserService = iUserService;
}
#Override
public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException, ServletException {
JSONObject requestBody= getRequestBody(httpServletRequest);
String username= requestBody.getString("username");
String password= requestBody.getString("password");
// get user info by username
SysUser sysUser= iUserService.getUserInfoByUsername(username);
//verify password
String encorderType=EncryptionAlgorithm.ENCODER_TYPE.get(1);
PasswordEncoder passwordEncoder =EncryptionAlgorithm.ENCODER_MAP.get(encorderType);
System.out.println(passwordEncoder);
System.out.println(sysUser);
System.out.println(password);
if(sysUser==null){
throw new UsernameNotFoundException("can't find userinfo by username:"+username);
}else if(!passwordEncoder.matches(password,sysUser.getPassword())){
throw new BadCredentialsException("password wrong!");
}else{
List<SysRole> list= iUserService.findRolesByUsername(username);
List<SimpleGrantedAuthority> simpleGrantedAuthorities= new ArrayList<SimpleGrantedAuthority>();
Iterator<SysRole> i=list.iterator();
while(i.hasNext()){
simpleGrantedAuthorities.add(new SimpleGrantedAuthority(i.next().getRoleName()));
}
return new UsernamePasswordAuthenticationToken(username,password,simpleGrantedAuthorities);
}
}
private JSONObject getRequestBody(HttpServletRequest request) throws AuthenticationException{
try {
StringBuilder stringBuilder = new StringBuilder();
InputStream inputStream = request.getInputStream();
byte[] bs = new byte[StreamUtils.BUFFER_SIZE];
int len;
while ((len = inputStream.read(bs)) != -1) {
stringBuilder.append(new String(bs, 0, len));
}
return JSON.parseObject(stringBuilder.toString());
} catch (IOException e) {
System.out.println("get request body error.");
}
throw new AuthenticationServiceException("invalid request body");
}
I would not write a custom security but use Spring Security, they have a strong library and has worked it out for you, it is a matter of configuration!
My Aproach was easy implemented! I have a user class where I store
Kotlin Code
var username: String? = null
var password: String? = null
var active: Boolean = false
var confirmationToken: String? = null // email confirmationToken sent # registration and other admin functions
var token: String? = null // If JWT token exist (not NULL or "") then the Networker is logged in with Client!
var roles: String? = null
var permissions: String? = null
ADD CONSTRUCTORS ....
val roleList: List<String>
get() = if (this.roles?.isNotEmpty()!!) {
listOf(*this.roles?.split(",".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray()!!)
} else ArrayList()
val permissionList: List<String>
get() = if (this.permissions?.isNotEmpty()!!) {
listOf(*this.permissions?.split(",".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray()!!)
} else ArrayList()
from there I config the securityConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
#Configuration
#EnableWebSecurity
class SecurityConfiguration(private val userPrincipalDetailService: UserPrincipalDetailService) :
WebSecurityConfigurerAdapter() {
override fun configure(auth: AuthenticationManagerBuilder) {
auth.authenticationProvider(authenticationProvider())
}
#Throws(Exception::class)
override fun configure(http: HttpSecurity) {
http.authorizeRequests()
.antMatchers("/index.html").permitAll()
.antMatchers("/security/**").permitAll()
.antMatchers("/profile/**").authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")
.and().formLogin()
.defaultSuccessUrl("/profile/index", true)
.loginProcessingUrl("/security/login")
.loginPage("/security/login").permitAll()
.usernameParameter("username")
.passwordParameter("password")
.and().logout()
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
.logoutRequestMatcher(AntPathRequestMatcher("/logout"))
.logoutSuccessUrl("/security/login")
.and()
.rememberMe().tokenValiditySeconds(2592000) // 2592000 = 30 days in Seconds
.rememberMeParameter("rememberMe")
}
private fun authenticationProvider(): DaoAuthenticationProvider {
val daoAuthenticationProvider = DaoAuthenticationProvider()
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder())
daoAuthenticationProvider.setUserDetailsService(this.userPrincipalDetailService)
return daoAuthenticationProvider
}
#Bean
internal fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
}
If you want to follow a Course in Spring Security - you can follow this one
Spring Boot Security by Romanian Coder

Spring-Security: Getting 401s with CORS Preflights (despite http.cors())

For starters i want to secure a part of my rest-api with basic auth.
When I try to access endpoints from a react client, I keep getting 401's in the preflight requests.
I tried to follow this guide without success:
https://www.baeldung.com/spring-security-cors-preflight
i'm not sure if this is part of the problem, but another part can only be accessed with certain custom http headers.
I'm using Method Security:
#Configuration
#EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = false)
class MethodSecurityConfig : GlobalMethodSecurityConfiguration() {
override fun customMethodSecurityMetadataSource(): MethodSecurityMetadataSource = SecurityMetadataSource()
override fun accessDecisionManager(): AccessDecisionManager = super.accessDecisionManager().apply {
this as AbstractAccessDecisionManager
decisionVoters.add(PrivilegeVoter())
}
}
And this is my Security config:
#Configuration
#EnableJpaAuditing(auditorAwareRef = "auditorProvider")
class SecurityConfig : WebSecurityConfigurerAdapter() {
private val deviceRequestHeaderName: String = "X-DEVICE-ID"
private val platformRequestHeaderName: String = "X-PLATFORM-ID"
#Autowired
lateinit var users: AppUserRepository
#Autowired
lateinit var backendUsers: BackendUserRepository
#Autowired
lateinit var roles: RoleRepository
val authManager by lazy { authenticationManager() }
private val authProvider by lazy {
PreAuthenticatedAuthenticationProvider().apply {
setPreAuthenticatedUserDetailsService {
val authId = it.principal as UserAuthId
if (authId.deviceId == null) throw UsernameNotFoundException("No device-id to search for.")
if (authId.platform == null) throw UsernameNotFoundException("Platform not specified.")
val platform = try {
ApplicationPlatform.valueOf(authId.platform)
} catch (e: IllegalArgumentException) {
throw UsernameNotFoundException("Unknown platform ${authId.platform}.")
}
val existingUser = users.findByUserDeviceIdAndPlatform(authId.deviceId, platform)
if (existingUser != null) return#setPreAuthenticatedUserDetailsService existingUser
users.save(AppUser(authId.deviceId, platform, roles))
}
}
}
val passwordEncoder by lazy { BCryptPasswordEncoder() }
private val deviceIdFilter by lazy {
HeaderFieldAuthFilter(deviceRequestHeaderName, platformRequestHeaderName).apply {
setAuthenticationManager(authManager)
}
}
override fun configure(auth: AuthenticationManagerBuilder) = auth {
authenticationProvider(authProvider)
val userDetailsService = BackendUserDetailsService(backendUsers)
userDetailsService(userDetailsService).passwordEncoder(passwordEncoder)
}
override fun configure(http: HttpSecurity) = http {
session {
sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
exceptionHandling()
addFilter(deviceIdFilter)
authorizeRequests().anyRequest().authenticated()
csrf().disable()
httpBasic()
cors().configurationSource { request ->
CorsConfiguration().apply {
allowedOrigins = listOf(ALL)
allowedMethods = listOf(GET, POST, DELETE, PUT, OPTIONS).map { it.name }
allowedHeaders = listOf(ALL)
allowCredentials = true
maxAge = 3600
}
}
}
#Bean
fun auditorProvider(): AuditorAware<User> = AuditorAware<User> {
val authentication = SecurityContextHolder.getContext().authentication
val user = authentication.run { if (isAuthenticated) principal as? User else null }
return#AuditorAware Optional.ofNullable(user)
}
}
I could solve by manually exclude the preflight requests from authentication.
adding this
antMatchers(OPTIONS, "/**").permitAll()
to the authorizeRequests() configuration accomplishes that.
Note that Options is a direct reference to the HttpMethod enum value, imported like this
import org.springframework.http.HttpMethod.*
Stackoverflow posts that helped me get there:
Response for preflight has invalid HTTP status code 401 - Spring
How to configure CORS in a Spring Boot + Spring Security application?
Originally I had assumed, that this should have been handled by the cors configuration - which it was apparently not.
To enable CORS for a single rest endpoint you can annotate it with:
#CrossOrigin
To allow for CORS for all endpoints you can have a bean like so:
#Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurerAdapter() {
#Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedMethods("GET", "POST", "PUT", "DELETE").allowedOrigins("*")
.allowedHeaders("*");
}
};

Spring Boot 2 OIDC (OAuth2) client / resource server not propagating the access token in the WebClient

Sample project available on Github
I have successfully configured two Spring Boot 2 application2 as client/resource servers against Keycloak and SSO between them is fine.
Besides, I am testing authenticated REST calls to one another, propagating the access token as an Authorization: Bearer ACCESS_TOKEN header.
After starting Keycloak and the applications I access either http://localhost:8181/resource-server1 or http://localhost:8282/resource-server-2 and authenticate in the Keycloak login page. The HomeController uses a WebClient to invoke the HelloRestController /rest/hello endpoint of the other resource server.
#Controller
class HomeController(private val webClient: WebClient) {
#GetMapping
fun home(httpSession: HttpSession,
#RegisteredOAuth2AuthorizedClient authorizedClient: OAuth2AuthorizedClient,
#AuthenticationPrincipal oauth2User: OAuth2User): String {
val authentication = SecurityContextHolder.getContext().authentication
println(authentication)
val pair = webClient.get().uri("http://localhost:8282/resource-server-2/rest/hello").retrieve()
.bodyToMono(Pair::class.java)
.block()
return "home"
}
}
This call returns a 302 since the request is not authenticated (it's not propagating the access token):
2019-12-25 14:09:03.737 DEBUG 8322 --- [nio-8181-exec-5] o.s.s.w.a.ExceptionTranslationFilter : Access is denied (user is anonymous); redirecting to authentication entry point
org.springframework.security.access.AccessDeniedException: Access is denied
at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:84) ~[spring-security-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:233) ~[spring-security-core-5.2.1.RELEASE.jar:5.2.1.RELEASE]
OAuth2Configuration:
#Configuration
class OAuth2Config : WebSecurityConfigurerAdapter() {
#Bean
fun webClient(): WebClient {
return WebClient.builder()
.filter(ServletBearerExchangeFilterFunction())
.build()
}
#Bean
fun clientRegistrationRepository(): ClientRegistrationRepository {
return InMemoryClientRegistrationRepository(keycloakClientRegistration())
}
private fun keycloakClientRegistration(): ClientRegistration {
val clientRegistration = ClientRegistration
.withRegistrationId("resource-server-1")
.clientId("resource-server-1")
.clientSecret("c00670cc-8546-4d5f-946e-2a0e998b9d7f")
.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid", "profile", "email", "address", "phone")
.authorizationUri("http://localhost:8080/auth/realms/insight/protocol/openid-connect/auth")
.tokenUri("http://localhost:8080/auth/realms/insight/protocol/openid-connect/token")
.userInfoUri("http://localhost:8080/auth/realms/insight/protocol/openid-connect/userinfo")
.userNameAttributeName(IdTokenClaimNames.SUB)
.jwkSetUri("http://localhost:8080/auth/realms/insight/protocol/openid-connect/certs")
.clientName("Keycloak")
.providerConfigurationMetadata(mapOf("end_session_endpoint" to "http://localhost:8080/auth/realms/insight/protocol/openid-connect/logout"))
.build()
return clientRegistration
}
override fun configure(http: HttpSecurity) {
http.authorizeRequests { authorizeRequests ->
authorizeRequests
.anyRequest().authenticated()
}.oauth2Login(withDefaults())
.logout { logout ->
logout.logoutSuccessHandler(oidcLogoutSuccessHandler())
}
}
private fun oidcLogoutSuccessHandler(): LogoutSuccessHandler? {
val oidcLogoutSuccessHandler = OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository())
oidcLogoutSuccessHandler.setPostLogoutRedirectUri(URI.create("http://localhost:8181/resource-server-1"))
return oidcLogoutSuccessHandler
}
}
As you can see I'm setting a ServletBearerExchangeFilterFunction in the WebClient. This is what I've seen debugging:
The SubscriberContext isn't setting anything because authentication.getCredentials() instanceof AbstractOAuth2Token is false. Actually it is just a String:
public class OAuth2AuthenticationToken extends AbstractAuthenticationToken {
...
#Override
public Object getCredentials() {
// Credentials are never exposed (by the Provider) for an OAuth2 User
return "";
}
What's the problem here? How can I automate the propagation of the token?
There doesn't seem to be an out of the box solution for pure OAuth2/OIDC login applications, I've created a Github issue for this.
In the meantime, I've created a specific ServletBearerExchangeFilterFunction that retrieves the access token from the OAuth2AuthorizedClientRepository.
This is my custom solution:
#Autowired
lateinit var oAuth2AuthorizedClientRepository: OAuth2AuthorizedClientRepository
#Bean
fun webClient(): WebClient {
val servletBearerExchangeFilterFunction = ServletBearerExchangeFilterFunction("resource-server-1", oAuth2AuthorizedClientRepository)
return WebClient.builder()
.filter(servletBearerExchangeFilterFunction)
.build()
}
...
private fun keycloakClientRegistration(): ClientRegistration {
return ClientRegistration
.withRegistrationId("resource-server-1")
...
const val SECURITY_REACTOR_CONTEXT_ATTRIBUTES_KEY = "org.springframework.security.SECURITY_CONTEXT_ATTRIBUTES"
class ServletBearerExchangeFilterFunction(private val clientRegistrationId: String,
private val oAuth2AuthorizedClientRepository: OAuth2AuthorizedClientRepository?) : ExchangeFilterFunction {
/**
* {#inheritDoc}
*/
override fun filter(request: ClientRequest, next: ExchangeFunction): Mono<ClientResponse> {
return oauth2Token()
.map { token: AbstractOAuth2Token -> bearer(request, token) }
.defaultIfEmpty(request)
.flatMap { request: ClientRequest -> next.exchange(request) }
}
private fun oauth2Token(): Mono<AbstractOAuth2Token> {
return Mono.subscriberContext()
.flatMap { ctx: Context -> currentAuthentication(ctx) }
.map { authentication ->
val authorizedClient = oAuth2AuthorizedClientRepository?.loadAuthorizedClient<OAuth2AuthorizedClient>(clientRegistrationId, authentication, null)
if (authorizedClient != null) {
authorizedClient.accessToken
} else {
Unit
}
}
.filter { it != null }
.cast(AbstractOAuth2Token::class.java)
}
private fun currentAuthentication(ctx: Context): Mono<Authentication> {
return Mono.justOrEmpty(getAttribute(ctx, Authentication::class.java))
}
private fun <T> getAttribute(ctx: Context, clazz: Class<T>): T? { // NOTE: SecurityReactorContextConfiguration.SecurityReactorContextSubscriber adds this key
if (!ctx.hasKey(SECURITY_REACTOR_CONTEXT_ATTRIBUTES_KEY)) {
return null
}
val attributes: Map<Class<T>, T> = ctx[SECURITY_REACTOR_CONTEXT_ATTRIBUTES_KEY]
return attributes[clazz]
}
private fun bearer(request: ClientRequest, token: AbstractOAuth2Token): ClientRequest {
return ClientRequest.from(request)
.headers { headers: HttpHeaders -> headers.setBearerAuth(token.tokenValue) }
.build()
}
}

Using WebClient to propagate request headers received in a Spring Webflux applications to downstream services

I have two kinds of Webflux applications, annotation-based and route-based. These applications are called with a set of headers, some of which (Open Tracing) I need to propagate in downstream calls using WebClient.
If these were normal Spring WebMvc applications I would use a Filter to keep the selected headers in a ThreadLocal, access it in a RestTemplate interceptor to send them to subsequent services and clear the ThreadLocal.
What's the proper way to replicate this behaviour in WebFlux applications?
I solved it using Project Reactor's Context to store the headers in a WebFilter. Then they are gotten in the WebClient's ExchangeFilterFunction. Here's the whole solution:
WebFilter
class OpenTracingFilter(private val openTracingHeaders: Set<String>) : WebFilter {
private val logger = LoggerFactory.getLogger(javaClass)
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
return chain.filter(exchange)
.subscriberContext { ctx ->
var updatedContext = ctx
exchange.request.headers.forEach {
if (openTracingHeaders.contains(it.key.toLowerCase())) {
logger.debug("Found OpenTracing Header - key {} - value {}", it.key, it.value[0])
updatedContext = updatedContext.put(it.key, it.value[0])
}
}
updatedContext
}
}
}
OpenTracingExchangeFilterFunction
class OpenTracingExchangeFilterFunction(private val headers: Set<String>) : ExchangeFilterFunction {
private val logger = LoggerFactory.getLogger(javaClass)
override fun filter(request: ClientRequest, next: ExchangeFunction): Mono<ClientResponse> {
logger.debug("OpenTracingExchangeFilterFunction - filter()")
return OpenTracingClientResponseMono(request, next, headers)
}
}
OpenTracingClientResponseMono
class OpenTracingClientResponseMono(private val request: ClientRequest,
private val next: ExchangeFunction,
private val headersToPropagate: Set<String>) : Mono<ClientResponse>() {
private val logger = LoggerFactory.getLogger(javaClass)
override fun subscribe(subscriber: CoreSubscriber<in ClientResponse>) {
val context = subscriber.currentContext()
val requestBuilder = ClientRequest.from(request)
requestBuilder.headers { httpHeaders ->
headersToPropagate.forEach {
if(context.hasKey(it)) {
logger.debug("Propagating header key {} - value{}", it, context.get<String>(it))
httpHeaders[it] = context.get<String>(it)
}
}
}
val mutatedRequest = requestBuilder.build()
next.exchange(mutatedRequest).subscribe(subscriber)
}
}
OpenTracingConfiguration
#Configuration
class OpenTracingConfiguration(private val openTracingConfigurationProperties: OpenTracingConfigurationProperties) {
#Bean
fun webClient(): WebClient {
return WebClient.builder().filter(openTracingExchangeFilterFunction()).build()
}
#Bean
fun openTracingFilter(): WebFilter {
return OpenTracingFilter(openTracingConfigurationProperties.headers)
}
#Bean
fun openTracingExchangeFilterFunction(): OpenTracingExchangeFilterFunction {
return OpenTracingExchangeFilterFunction(openTracingConfigurationProperties.headers)
}
}
OpenTracingConfigurationProperties
#Configuration
#ConfigurationProperties("opentracing")
class OpenTracingConfigurationProperties {
lateinit var headers: Set<String>
}
application.yml
opentracing:
headers:
- x-request-id
- x-b3-traceid
- x-b3-spanid
- x-b3-parentspanid
- x-b3-sampled
- x-b3-flags
- x-ot-span-context
I needed to pass x-request-id header to a downstream service in my application. Achieved this by adding WebFilter that writes x-request-id to a reactor context
class ContextWebFilter : WebFilter {
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val headers = exchange.request.headers
val xRequestId = headers[X_REQUEST_ID]?.firstOrNull() ?: ""
val requestId = xRequestId.ifBlank { UUID.randomUUID().toString() }
return chain
.filter(exchange)
.contextWrite { it.put(X_REQUEST_ID, requestId) }
}
companion object {
const val X_REQUEST_ID = "X-REQUEST-ID"
}
}
and updating WebClient with ExchangeFilterFunction that updates outgoing request
WebClient.builder()
.filter(
ExchangeFilterFunction.ofRequestProcessor { request ->
Mono.deferContextual { context ->
val xRId = context.getOrDefault<String>("X-REQUEST-ID", "")
logger.debug("Set X-REQUEST-ID={} as a header to outgoing call", xRId)
Mono.just(
ClientRequest.from(request)
.header("X-REQUEST-ID", xRId)
.build()
)
}
}
)
.baseUrl("http://localhost:8080")
.build()

Resources