Session not replicated on session creation with Spring Boot, Session, and Redis - spring

I'm attempting to implement a microservices architecture using Spring Cloud's Zuul, Eureka, and my own services. I have multiple services that have UIs and services and each can authenticate users using x509 security. Now I'm trying to place Zuul in front of those services. Since Zuul can't forward client certs to the backend, I thought the next best thing would be to authenticate the user at the front door in Zuul, then use Spring Session to replicate their authenticated state across the backend services. I have followed the tutorial here from Dave Syer and it almost works, but not on the first request. Here is my basic setup:
Zuul Proxy in it's own application set to route to the backend services. Has Spring security enabled to do x509 auth. Successfully auths users. Also has Spring Session with #EnableRedisHttpSession
Backend service also has spring security enabled. I have tried both enabling/disabling x509 here but always requiring the user to be authenticated for specific endpoints. Also uses Spring Session and #EnableRedisHttpSession.
If you clear all the sessions and start fresh and try to hit the proxy, then it sends the request to the backend using the zuul server's certificate. The backend service then looks up the user based on that user cert and thinks the user is the server, not the user that was authenticated in the Zuul proxy. If you just refresh the page, then you suddenly become the correct user on the back end (the user authenticated in the Zuul proxy). The way I'm checking is to print out the Principal user in the backend controller. So on first request, I see the server user, and on second request, I see the real user. If I disable x509 on the back end, on the first request, I get a 403, then on refresh, it lets me in.
It seems like the session isn't replicated to the backend fast enough so when the user is authenticated in the frontend, it hasn't made it to the backend by the time Zuul forwards the request.
Is there a way to guarantee the session is replicated on the first request (i.e. session creation)? Or am I missing a step to ensure this works correctly?
Here are some of the important code snippets:
Zuul Proxy:
#SpringBootApplication
#Controller
#EnableAutoConfiguration
#EnableZuulProxy
#EnableRedisHttpSession
public class ZuulEdgeServer {
public static void main(String[] args) {
new SpringApplicationBuilder(ZuulEdgeServer.class).web(true).run(args);
}
}
Zuul Config:
info:
component: Zuul Server
endpoints:
restart:
enabled: true
shutdown:
enabled: true
health:
sensitive: false
zuul:
routes:
service1: /**
logging:
level:
ROOT: INFO
# org.springframework.web: DEBUG
net.acesinc: DEBUG
security.sessions: ALWAYS
server:
port: 8443
ssl:
key-store: classpath:dev/localhost.jks
key-store-password: thepassword
keyStoreType: JKS
keyAlias: localhost
clientAuth: want
trust-store: classpath:dev/localhost.jks
ribbon:
IsSecure: true
Backend Service:
#SpringBootApplication
#EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class, ThymeleafAutoConfiguration.class, org.springframework.boot.autoconfigure.security.SecurityAutoConfiguration.class })
#EnableEurekaClient
#EnableRedisHttpSession
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Backend Service Config:
spring.jmx.default-domain: ${spring.application.name}
server:
port: 8444
ssl:
key-store: classpath:dev/localhost.jks
key-store-password: thepassword
keyStoreType: JKS
keyAlias: localhost
clientAuth: want
trust-store: classpath:dev/localhost.jks
#Change the base url of all REST endpoints to be under /rest
spring.data.rest.base-uri: /rest
security.sessions: NEVER
logging:
level:
ROOT: INFO
# org.springframework.web: INFO
# org.springframework.security: DEBUG
net.acesinc: DEBUG
eureka:
instance:
nonSecurePortEnabled: false
securePortEnabled: true
securePort: ${server.port}
homePageUrl: https://${eureka.instance.hostname}:${server.port}/
secureVirtualHostName: ${spring.application.name}
One of the Backend Controllers:
#Controller
public class SecureContent1Controller {
private static final Logger log = LoggerFactory.getLogger(SecureContent1Controller.class);
#RequestMapping(value = {"/secure1"}, method = RequestMethod.GET)
#PreAuthorize("isAuthenticated()")
public #ResponseBody String getHomepage(ModelMap model, Principal p) {
log.debug("Secure Content for user [ " + p.getName() + " ]");
model.addAttribute("pageName", "secure1");
return "You are: [ " + p.getName() + " ] and here is your secure content: secure1";
}
}

Thanks to shobull for pointing me to Justin Taylor's answer to this problem. For completeness, I wanted to put the full answer here too. It's a two part solution:
Make Spring Session commit eagerly - since spring-session v1.0 there is annotation property #EnableRedisHttpSession(redisFlushMode = RedisFlushMode.IMMEDIATE) which saves session data into Redis immediately. Documentation here.
Simple Zuul filter for adding session into current request's header:
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import javax.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import org.springframework.stereotype.Component;
#Component
public class SessionSavingZuulPreFilter extends ZuulFilter {
#Autowired
private SessionRepository repository;
private static final Logger log = LoggerFactory.getLogger(SessionSavingZuulPreFilter.class);
#Override
public String filterType() {
return "pre";
}
#Override
public int filterOrder() {
return 1;
}
#Override
public boolean shouldFilter() {
return true;
}
#Override
public Object run() {
RequestContext context = RequestContext.getCurrentContext();
HttpSession httpSession = context.getRequest().getSession();
Session session = repository.getSession(httpSession.getId());
context.addZuulRequestHeader("Cookie", "SESSION=" + httpSession.getId());
log.trace("ZuulPreFilter session proxy: {}", session.getId());
return null;
}
}
Both of these should be within your Zuul Proxy.

Spring Session support currently writes to the data store when the request is committed. This is to try to reduce "chatty traffic" by writing all attributes at once.
It is recognized that this is not ideal for some scenarios (like the one you are facing). For these we have spring-session/issues/250. The workaround is to copy RedisOperationsSessionRepository and invoke saveDelta anytime property is changed.

Related

Multi-tenant Spring Webflux microservice with Keycloak OAuth/OIDC

Use Case:
I am building couple of reactive microservices using spring webflux.
I am using Keycloak as authentication and authorization server.
Keycloak realms are used as tenants where tenant/realm specific clients and users are configured.
The client for my reactive microservice is configured in each realm of Keycloak with the same client id & name.
The microservice REST APIs would be accessed by users of different realms of Keycloak.
The APIs would be accessed by user using UX (developed in React) publicly as well as by other webflux microservices as different client internally.
The initial part of the REST API would contain tenant information e.g. http://Service-URI:Service-Port/accounts/Keycloak-Realm/Rest-of-API-URI
Requirements:
When the API is called from UX, I need to invoke authorization code grant flow to authenticate the user using the realm information present in the request URI. The user (if not already logged in) should be redirected to the login page of correct realm (present in the request URI)
When the API is called from another webflux microservice, it should invoke client credential grant flow to authenticate and authorize the caller service.
Issue Faced:
I tried to override ReactiveAuthenticationManagerResolver as below:
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders;
import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
#Component
public class TenantAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver<ServerWebExchange> {
private static final String ACCOUNT_URI_PREFIX = "/accounts/";
private static final String ACCOUNTS = "/accounts";
private static final String EMPTY_STRING = "";
private final Map<String, String> tenants = new HashMap<>();
private final Map<String, JwtReactiveAuthenticationManager> authenticationManagers = new HashMap<>();
public TenantAuthenticationManagerResolver() {
this.tenants.put("neo4j", "http://localhost:8080/realms/realm1");
this.tenants.put("testac", "http://localhost:8080/realms/realm2");
}
#Override
public Mono<ReactiveAuthenticationManager> resolve(ServerWebExchange exchange) {
return Mono.just(this.authenticationManagers.computeIfAbsent(toTenant(exchange), this::fromTenant));
}
private String toTenant(ServerWebExchange exchange) {
try {
String tenant = "system";
ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
if (path.startsWith(ACCOUNT_URI_PREFIX)) {
tenant = extractAccountFromPath(path);
}
return tenant;
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
private JwtReactiveAuthenticationManager fromTenant(String tenant) {
return Optional.ofNullable(this.tenants.get(tenant))
.map(ReactiveJwtDecoders::fromIssuerLocation)
.map(JwtReactiveAuthenticationManager::new)
.orElseThrow(() -> new IllegalArgumentException("Unknown tenant"));
}
private String extractAccountFromPath(String path) {
String removeAccountTag = path.replace(ACCOUNTS, EMPTY_STRING);
int indexOfSlash = removeAccountTag.indexOf("/");
return removeAccountTag.substring(indexOfSlash + 1, removeAccountTag.indexOf("/", indexOfSlash + 1));
}
}
Then I used the overridden TenantAuthenticationManagerResolver class in to SecurityWebFilterChain configuration as below:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;
#Configuration
#EnableWebFluxSecurity
#EnableReactiveMethodSecurity
public class SecurityConfig {
#Autowired
TenantAuthenticationManagerResolver authenticationManagerResolver;
#Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.csrf().disable()
.authorizeExchange()
.pathMatchers("/health").hasAnyAuthority("ROLE_USER")
.anyExchange().authenticated()
.and()
.oauth2Client()
.and()
.oauth2Login()
.and()
.oauth2ResourceServer()
.authenticationManagerResolver(authenticationManagerResolver);
return http.build();
}
}
Blow is the configuration in application.properties:
server.port=8090
logging.level.org.springframework.security=DEBUG
spring.security.oauth2.client.registration.keycloak.provider=keycloak
spring.security.oauth2.client.registration.keycloak.client-id=test-client
spring.security.oauth2.client.registration.keycloak.client-secret=ZV4kAKjeNW2KEnYejojOCsi0vqt9vMiS
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.scope=openid
spring.security.oauth2.client.registration.keycloak.redirect-uri={baseUrl}/login/oauth2/code/keycloak
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8080/realms/master
When I call the API using master realm e.g. http://localhost:8090/accounts/master/health it redirects the user to Keycloak login page of master realm and once I put the user id and password of a user of master realm, the API call is successful.
When I call the API using any other realm e.g. http://localhost:8090/accounts/realm1/health it still redirects the user to Keycloak login page of master realm and if I put the user id and password of a user of realm1 realm, the login is not successful.
So it seems that multi-tenancy is not working as expected and it is only working for the tenant configured in application.properties.
What is missing in my implementation w.r.t. multi-tenancy?
How to pass the client credentials for different realms?
I tried to use JWKS in place of client credentials but somehow it is not working. Below is the configuration used for JWKS in application.properties.
spring.security.oauth2.client.registration.keycloak.keystore=C:\\Work\\test-client.jks
spring.security.oauth2.client.registration.keycloak.keystore-type=JKS
spring.security.oauth2.client.registration.keycloak.keystore-password=changeit
spring.security.oauth2.client.registration.keycloak.key-password=changeit
spring.security.oauth2.client.registration.keycloak.key-alias=proactive-outreach-admin
spring.security.oauth2.client.registration.keycloak.truststore=C:\\Work\\test-client.jks
spring.security.oauth2.client.registration.keycloak.truststore-password=changeit
Note: JWKS is not event working for master realm configured in application.properties.
Need help here as I am stuck for many days without any breakthrough. Let me know if any more information is required.
Client V.S. resource-server configuration
Authentication is not the responsibility of the resource-servers: request arrive authorized (or not if some endpoints accept anonymous requests) with a Bearer access-token. Do not implement redirection to login there, just return 401 if authentication is missing.
It is the responsibility of OAuth2 clients to acquire (and maintain) access-tokens, with sometimes other flows than authorization-code, even to authenticate users: won't you use refresh-token flow for instance?
This OAuth2 client could be either a "public" client in a browser (your react app) or a BFF in between the browser and the resource-servers (spring-cloud-gateway for instance). The later is considered more secure because tokens are kept on your servers and is a rather strong recommendation lately.
If you want both client (for oauth2Login) and resource-server (for securing a REST API) configurations in your app, define two separated security filter-chains as exposed in this other answer: Use Keycloak Spring Adapter with Spring Boot 3
Multi-tenancy in Webflux with JWT decoder
The recommended way is to override the default authentication-manager resolver with one capable of providing the right authentication-manager depending on the access-token issuer (or header or whatever from the request): http.oauth2ResourceServer().authenticationManagerResolver(authenticationManagerResolver)
To easily configure multi-tenant reactive resource-servers (with JWT decoder and an authentication-manager switching the tenant based on token issuer), you should have a look at this Spring Boot starter I maintain: com.c4-soft.springaddons:spring-addons-webflux-jwt-resource-server. sample usage here and tutorials there. As it is open-source, you can also browse the source to see in details how this is done and what this reactive multi-tenant authentication-manager looks like.

Spring cloud load-balancer drops instances after cache refresh

I have a need to save Spring Cloud Gateway routes within a database and to do this we send a web request using the WebClient to another microservice.
I'm using Eureka for service discovery and want the WebClient to use discovery instance names instead of explicit URLs and I've therefore utilised the #LoadBalanced annotation on the bean method:
#Bean
public WebClient loadBalancedWebClientBuilder(WebClient.Builder builder) {
return builder
.exchangeStrategies(exchangeStrategies())
.build();
}
#Bean
#LoadBalanced
WebClient.Builder builder() {
return WebClient.builder();
}
private ExchangeStrategies exchangeStrategies() {
return ExchangeStrategies.builder()
.codecs(clientCodecConfigurer -> {
clientCodecConfigurer.defaultCodecs().jackson2JsonEncoder(getEncoder());
clientCodecConfigurer.defaultCodecs().jackson2JsonDecoder(getDecoder());
}).build();
}
This all works on start-up and for the default 35s cache time - i.e. the webClient discovers the required 'saveToDatabase' service instance and sends the request.
On each eventPublisher.publishEvent(new RefreshRoutesEvent(this)) a call is made to the same downstream microservice (via the WebClient) to retrieve all saved routes.
Again this works initially, but after the default 35seconds the load balancer cache seems to be cleared and the downstream service id can no longer be found:
WARN o.s.c.l.core.RoundRobinLoadBalancer - No servers available for service: QUERY-SERVICE
I have confirmed it is the cache refresh purging the cache and not re-acquiring the instances by setting
spring:
application:
name: my-gateway
cloud:
loadbalancer:
cache:
enabled: true
ttl: 240s
health-check:
refetch-instances: true
ribbon:
enabled: false
gateway:
...
I've struggled with this for days now and cannot find/ see where or why the cache is not being updated, only purged. Adding specific #LoadBalancerClient() configuration as below makes no difference.
#Bean
public ServiceInstanceListSupplier instanceSupplier(ConfigurableApplicationContext context) {
return ServiceInstanceListSupplier.builder()
.withDiscoveryClient()
.withHealthChecks()
.withCaching()
.withRetryAwareness()
.build(context);
}
Clearly this must work for other people, so what am I missing?!
Thanks.

Spring Boot Social Login and Google Calendar API

Problem
Reuse End-User Google Authentication via Spring Security OAuth2 to access Google Calendar API in Web Application
Description
I was able to create a small Spring Boot Web application with Login through Spring Security
application.yaml
spring:
security:
oauth2:
client:
registration:
google:
client-id: <id>
client-secret: <secret>
scope:
- email
- profile
- https://www.googleapis.com/auth/calendar.readonly
When application starts I can access http://localhost:8080/user and user is asked for google login. After successful login profile json is shown in a browser as the response from:
SecurityController
#RestController
class SecurityController {
#RequestMapping("/user")
fun user(principal: Principal): Principal {
return principal
}
}
SecurityConfiguration.kt
#Configuration
class SecurityConfiguration : WebSecurityConfigurerAdapter() {
#Throws(Exception::class)
override fun configure(http: HttpSecurity) {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2Login()
}
}
Question
I want to reuse this authentication to retrieve all user's Calendar Events. The following code is taken from google's tutorial on accessing calendar API but it creates a completely independent authorization flow and asks user to log in.
#Throws(IOException::class)
private fun getCredentials(httpTransport: NetHttpTransport): Credential {
val clientSecrets = loadClientSecrets()
return triggerUserAuthorization(httpTransport, clientSecrets)
}
private fun loadClientSecrets(): GoogleClientSecrets {
val `in` = CalendarQuickstart::class.java.getResourceAsStream(CREDENTIALS_FILE_PATH)
?: throw FileNotFoundException("Resource not found: $CREDENTIALS_FILE_PATH")
return GoogleClientSecrets.load(JSON_FACTORY, InputStreamReader(`in`))
}
private fun triggerUserAuthorization(httpTransport: NetHttpTransport, clientSecrets: GoogleClientSecrets): Credential {
val flow = GoogleAuthorizationCodeFlow.Builder(
httpTransport, JSON_FACTORY, clientSecrets, SCOPES)
.setDataStoreFactory(FileDataStoreFactory(File(TOKENS_DIRECTORY_PATH)))
.setAccessType("offline")
.build()
val receiver = LocalServerReceiver.Builder().setPort(8880).build()
return AuthorizationCodeInstalledApp(flow, receiver).authorize("user")
}
How can I reuse already done authentication to access end user's calendar events on Google account?
If I understand correctly, what you mean be reusing the authentication is that you want to use the access and refresh tokens Spring retrieved for you in order to use them for requests against Google API.
The user authentication details can be injected into an endpoint method like this:
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
#RestController
class FooController(val historyService: HistoryService) {
#GetMapping("/foo")
fun foo(#RegisteredOAuth2AuthorizedClient("google") user: OAuth2AuthorizedClient) {
user.accessToken
}
}
With the details in OAuth2AuthorizedClient you should be able to do anything you need with the google API.
If you need to access the API without a user making a request to your service, you can inject OAuth2AuthorizedClientService into a managed component, and use it like this:
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService
import org.springframework.stereotype.Service
#Service
class FooService(val clientService: OAuth2AuthorizedClientService) {
fun foo() {
val user = clientService.loadAuthorizedClient<OAuth2AuthorizedClient>("google", "principal-name")
user.accessToken
}
}

SpringBoot LDAPTemplate Embedded vs Real LDAP

I am using the below mentioned properties in my SpringBoot App, in application.yml file to have the LDAP Code run in my local machine.
spring:
ldap:
# Use this embedded configuration for local ldap testing
embedded:
base-dn: o=localcompany,c=US
credential:
username: uid=admin
password: secret
ldif: classpath:schemas.ldif
port: 12345
validation:
enabled: false
# Use this below configuration for Ford ldap
# urls: ldaps://mmm.mmm.com:754
# base-dn: o=****,c=US
# username:
# password: {your password goes here}
I want to have both my embedded configuration & actual configuration exist in my Application, so that it works locally as well as in my Cloud Environment. But having Embedded properties in my yml file is overwriting the actual ones even in Cloud Environment. Is there a way to have both the properties and then according to the Environment, wire the LDAPTemplate
I configured my LDAPTemplate using #profile annotation that would differentiate the local and Server environment and achieved what I asked above. Below is my configuration. For the Local environment, having the embedded-properties are sufficient to have LDAPTemplate wired properly
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.core.support.LdapContextSource;
#Configuration
#Profile("cloud")
public class LDAPConfiguration {
#Value("${ldap.url}")
private String ldapUrl;
#Value("${ldap.base}")
private String ldapBase;
#Value("${ldap.username}")
private String ldapUser;
#Value("${ldap.password}")
private String ldapPassword;
#Bean
public LdapTemplate configureLdapTemplateForCloud() {
return new LdapTemplate(contextSource()) ;
}
private LdapContextSource contextSource() {
LdapContextSource ldapContextSource = new LdapContextSource();
ldapContextSource.setUrl(ldapUrl);
ldapContextSource.setBase(ldapBase);
ldapContextSource.setUserDn(ldapUser);
ldapContextSource.setPassword(ldapPassword);
ldapContextSource.afterPropertiesSet();
return ldapContextSource;
}
}
So now, when I run in my local, Spring Boot will play with my Embedded LDAP, but in the cloud profile, it will execute the actual LDAP Server.

Tomcat based Zuul Proxy server does not forward correct client IP address (request.getRemoteAddr()) to end microservice

I have Spring Boot based micro-services environment setup with this configuration -
nginx as the load balancer on centos 7. (configured to use x-forwarded-for)
Spring boot powered API Gateway based on netflix zuul proxy server. Tomcat is embedded container.
Products micro-service based on Spring Cloud. Tomcat as the embedded container.
Spring Boot version : 1.5.6
When end user is making call to Product Microservice, it goes through Nginx -> Api gateway -> Products Service.
Now the problem comes when i want to get the Remote Client IP Address in products Microservice. I always get 127.0.0.1 as the ip address. Here is the code in Products microservice that fetches client IP
private String getClientIP() {
String xfHeader = request.getRemoteAddr();
if (StringUtils.isBlank(xfHeader) || xfHeader.equals("127.0.0.1")) {
return request.getHeader("X-Forwarded-For");
}
return xfHeader.split(",")[0];
}
API Gateway application.properties are configured to use server.use-forward-headers: true
P.S.
When i tries switching from tomcat to undertow in my api-gateway, then i start getting the real client ip address in products microservice. So problem lies somewhere in my Tomcat Configuration in API Gateway.
you can create zuul filter and change location like this
zuul:
ignoreSecurityHeaders: false
routes:
app:
path: /app/**
sensitiveHeaders:
url: http://localhost:8082/app/
server:
compression:
enabled: true
port: 80
and filter
package smartHomeWebsite;
import java.util.Optional;
import org.springframework.cloud.netflix.zuul.filters.Route;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UrlPathHelper;
import com.netflix.util.Pair;
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
#Component
public class LocationHeaderRewritingFilter extends ZuulFilter {
private final UrlPathHelper urlPathHelper = new UrlPathHelper();
private final RouteLocator routeLocator;
public LocationHeaderRewritingFilter(RouteLocator routeLocator) {
this.routeLocator = routeLocator;
}
#Override
public String filterType() {
return "post";
}
#Override
public int filterOrder() {
return 100;
}
public boolean shouldFilter() {
return extractLocationHeader(RequestContext.getCurrentContext()).isPresent();
}
public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
Route route = routeLocator.getMatchingRoute(urlPathHelper.getPathWithinApplication(ctx.getRequest()));
if (route != null) {
Pair<String, String> lh = extractLocationHeader(ctx).get();
lh.setSecond(lh.second().replace(route.getLocation(),
ServletUriComponentsBuilder.fromCurrentContextPath().path(route.getPrefix()).build().toUriString()));
}
return null;
}
private Optional<Pair<String, String>> extractLocationHeader(RequestContext ctx) {
return ctx.getZuulResponseHeaders()
.stream()
.filter(p -> "Location".equals(p.first()))
.findFirst();
}
}

Resources