Search code examples
javaangularspring-cloud-gatewayspring-authorization-serverspring-boot-starter-oauth2-client

Session management in a spring-oauth2-client that works as a gateway


I am implementing a microservices application and for the authentication issue I have used something similar to what @ch4mp proposed in his https://www.baeldung.com/spring-cloud-gateway-bff-oauth2 tutorial. The difference is that my oauth2Client works also as a gateway, I also have an angular application.

The problem I have is that I don't know how to handle the session issue, since the login works as it should, the authorization is requested to my authorization server, it is validated and through a cookieSession I can enter to my dashboard. My accessToken and RefreshToken are set with a duration of 5 minutes and 1 hour respectively. And for testing purposes I have set the session to last 30 minutes. All the logout flow works as it should, for this I use the OIDC Logout standard in which the id_token is used. The problem arises that when the session expires, I have to refresh the page so that I reridiga to /home but at the time of logging in again no longer asks me the credentials but enters directly as if I had already entered the credentials.

So according to this what would be the best solution? I thought that the session should not I was thinking that the session should not expire but by doing this then I should limit the number of sessions a user should have?

My SecurityConfigClient:

@Configuration
@EnableWebFluxSecurity
public class ClientSecurityConfig {

    @Autowired
    private ReactiveClientRegistrationRepository clientRegistrationRepository;

    @Value("${intechbo.server.gateway}")
    private String gatewayUrl;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         ServerOAuth2AuthorizationRequestResolver resolver) {
        http
                .cors(ServerHttpSecurity.CorsSpec::disable)
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .authorizeExchange(
                        exchanges -> exchanges
                                .pathMatchers(SecurityConstants.AUTH_WHITELIST).permitAll()
                                .pathMatchers("/*.js", "/*.css", "/*.ico", "/*.jpg", "/*.png", "/*.html", "/*.svg").permitAll()
                                .pathMatchers(SecurityConstants.AUTH_ANGULAR_COMPILER_WHITELIST).permitAll()
                                .pathMatchers("/backoffice/home/**").permitAll()
                                .pathMatchers("/backoffice/home").permitAll()
                                .pathMatchers("/backoffice/authentication/logout").permitAll()
                                .pathMatchers("/backoffice/profile/**").authenticated()
                                .pathMatchers("/logged-out").permitAll()
                                .pathMatchers("/authenticate").authenticated()
                                .anyExchange().authenticated()
                )
                .oauth2Login(auth ->
                        auth.authorizationRequestResolver(resolver)
                                .authenticationSuccessHandler(new CustomServerAuthenticationSuccessHandler("/backoffice/authentication/login"))
                )
                .oauth2Client(Customizer.withDefaults())
                .logout(
                        logout -> logout
                                .logoutUrl("/logout")
                                .logoutSuccessHandler(oidcLogoutSuccessHandler())
                )
                .exceptionHandling(
                        exceptionHandlingSpec -> exceptionHandlingSpec
                                .authenticationEntryPoint((swe, e) -> {
                                    ServerHttpResponse response = swe.getResponse();
                                    response.setStatusCode(HttpStatus.SEE_OTHER);
                                    response.getHeaders().setLocation(URI.create("/backoffice/home"));
                                    return response.setComplete();
                                })
                );
        return http.build();
    }

    private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
        OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler =
                new OidcClientInitiatedServerLogoutSuccessHandler(this.clientRegistrationRepository);

        // Sets the location that the End-User's User Agent will be redirected to
        // after the logout has been performed at the Provider
        oidcLogoutSuccessHandler.setPostLogoutRedirectUri(gatewayUrl + "/logged-out");

        return oidcLogoutSuccessHandler;
    }

    @Bean
    public ServerOAuth2AuthorizationRequestResolver pkceResolver(ReactiveClientRegistrationRepository repo) {
        DefaultServerOAuth2AuthorizationRequestResolver resolver = new DefaultServerOAuth2AuthorizationRequestResolver(repo);
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
        return resolver;
    }

    @Bean
    public WebClient webClient(ReactiveOAuth2AuthorizedClientManager authorizedClientManager) {
        ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
                new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        oauth2Client.setDefaultOAuth2AuthorizedClient(true);
        return WebClient.builder()
                .filter(oauth2Client)
                .build();
    }

    @Bean
    public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(ReactiveClientRegistrationRepository clientRegistrationRepository,
                                                                         ServerOAuth2AuthorizedClientRepository authorizedClientRepository) {
        ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider =
                ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
                        .authorizationCode()
                        .refreshToken()
                        .build();
        DefaultReactiveOAuth2AuthorizedClientManager authorizedClientManager =
                new DefaultReactiveOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
        return authorizedClientManager;
    }

}

My Configuration to set maxInactiveIntervalInSeconds:

@Configuration
@EnableRedisWebSession(redisNamespace = "inclub:session", maxInactiveIntervalInSeconds = 600)
public class SessionConfig {

}

My application.yml:

logging:
  level:
    org.springframework.cloud.gateway.handler.RoutePredicateHandlerMapping: DEBUG
    org:
      springframework:
        security: DEBUG
        session: DEBUG
        web: DEBUG


spring:
#  cache:
#    redis:
#      time-to-live: 60000
  application:
    name: bo-gateway-server
  session:
    redis:
      repository-type: default
#    timeout: 10m
  security:
    oauth2:
      client:
        registration:
          backoffice-gateway:
            provider: spring
            client-id: example-client
            client-secret: 
            authorization-grant-type: authorization_code
            redirect-uri: ${intechbo.server.gateway}/login/oauth2/code/backoffice-gateway
            scope: read,write,openid,profile
        provider:
          spring:
            issuer-uri: ${intechbo.server.oauth}

  cloud:
    gateway:
      default-filters:
        - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
        - TokenRelay=
        - SaveSession
      routes:
        - id: pets-service-route
          uri: uri
          predicates:
            - Path=/api/v1/breeds/**
          filters:
            - name: Retry
              args:
                retries: 5
                methods: GET
                backoff:
                  firstBackoff: 50ms
                  maxBackOff: 400ms
            - name: CircuitBreaker
              args:
                name: petsService
                fallbackUri: forward:/pets-service-fallback
            - name: RequestRateLimiter
              args:
                key-resolver: "#{@userKeyResolver}"
                redis-rate-limiter.replenishRate: 2
                redis-rate-limiter.burstCapacity: 2

        -  id: account-service-route
#           uri: http://localhost:8776
           uri: uri
           predicates:
             - Path=/api/v1/account/**
           filters:
             - name: Retry
               args:
                 retries: 5
                 methods: GET
                 backoff:
                   firstBackoff: 50ms
                   maxBackOff: 400ms
             - name: CircuitBreaker
               args:
                 name: accountService
                 fallbackUri: forward:/account-service-fallback
             - name: RequestRateLimiter
               args:
                 key-resolver: "#{@userKeyResolver}"
                 redis-rate-limiter.replenishRate: 2
                 redis-rate-limiter.burstCapacity: 2

        - id: membership-service-route
          uri: uri
          predicates:
            - Path=/api/v1/membership/**, /api/v1/pay/** , /api/v1/store/**
          filters:
            - name: RequestRateLimiter
              args:
                key-resolver: "#{@userKeyResolver}"
                redis-rate-limiter.replenishRate: 2
                redis-rate-limiter.burstCapacity: 2

        - id: treepointrange-service-route
          uri: uri
          predicates:
            - Path=/api/v1/three/**, /api/v1/placement/**
          filters:
            - name: RequestRateLimiter
              args:
                key-resolver: "#{@userKeyResolver}"
                redis-rate-limiter.replenishRate: 2
                redis-rate-limiter.burstCapacity: 2

        - id: wallet-service-route
          uri: uri
          predicates:
            - Path=/api/v1/wallet/**, /api/v1/wallettransaction/**, /api/v1/withdrawalrequest/**, /api/v1/tokenwallet/**, /api/v1/electronicpurse/**, /api/v1/accountbank/**
          filters:
            - name: RequestRateLimiter
              args:
                key-resolver: "#{@userKeyResolver}"
                redis-rate-limiter.replenishRate: 2
                redis-rate-limiter.burstCapacity: 2

#        - id: angular
#          uri: ${intechbo.server.webapp}
#          predicates:
#            - Path=/backoffice/**
#          filters:
##            - RewritePath=/backoffice(?<segment>/?.*), /$\\{segment}
#            - RewritePath=/backoffice(?<segment>/?.*), "/\\$\\{segment}"
        - id: angular
          uri: ${intechbo.server.webapp}
          predicates:
            - Path=/
          filters:
            - RewritePath=/, /backoffice
        - id: static
          uri: ${intechbo.server.webapp}
          predicates:
            - Path=/**

  data:
    redis:
      port: ${REDIS_SERVER_PORT:6379}
      host: ${REDIS_SERVER_HOST:localhost}
      password: ${REDIS_SERVER_PASSWORD:}
      timeout: 5000
      lettuce:
        pool:
          max-idle: 9
          min-idle: 1
          max-active: 9
          max-wait: 5000

eureka:
  client:
    service-url:
      defaultZone: ${intechbo.server.discover}
    fetch-registry: true
    register-with-eureka: true

server:
  port: 8090
#  reactive:
#    session:
#      timeout: 1m
  reactive:
    session:
      timeout: 10m
      cookie:
        name: INTECHBOSESSION
        max-age: 10m

resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowSize: 10
        slidingWindowType: COUNT_BASED
        permittedNumberOfCallsInHalfOpenState: 6
        failureRateThreshold: 50
        waitDurationInOpenState: 10s
        registerHealthIndicator: true
        automaticTransitionFromOpenToHalfOpenEnabled: true

    instances:
      petsService:
        baseConfig: default

  retry:
    instances:
      authorizationServer:
        maxAttempts: 3
        waitDuration: 2500ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2

  timelimiter:
    configs:
      values:
        timeout-duration: 80s
    instances:
      offersTimeLimiter: # Unique name for TimeLimiter
        base-config: values

management:
  health:
    circuitbreakers:
      enabled: true
  endpoints:
    web:
      exposure:
        include: "*"
  endpoint:
    health:
      show-details: always

If I were to choose to never invalidate at some point I may have problems in my redis database where I am storing this?


Solution

  • Most probably, a new login completes successfully, silently because the user session is still valid on the authorization server (SSO auto-login).

    You have two distinct sessions: one on the BFF (the Spring Cloud Gateway configured with oauth2Login) and a different one on the authorization server.

    Also, remember that when a Spring client session expires, what happens is token deletion on the client, not a logout from the authorization server (RP-initiated Logout involves the user agent, which is not there when a session times out).

    In your scenario, it is very likely that the BFF session had expired, but that the session on the authorization server was still valid. Because the BFF session expired, the tokens kept in session were lost and the user was redirected to the authorization server. But as the session on the authorization server was still valid (no Logout was performed and authorization server sessions are usually very long), an authorization code was returned without displaying the login form (code with which the Spring client with oauth2Login could authorize the new session with fresh tokens).