spring-projects / spring-data-redis

Provides support to increase developer productivity in Java when using Redis, a key-value store. Uses familiar Spring concepts such as a template classes for core API usage and lightweight repository style data access.
https://spring.io/projects/spring-data-redis/
Apache License 2.0
1.77k stars 1.17k forks source link

Spring Boot / Secuurity with AWS Elasticache - getting strange Session ID behaviour #3026

Closed dreamstar-enterprises closed 1 month ago

dreamstar-enterprises commented 1 month ago

Redis version

AWS Elasticache 7.1 (not AWS Serverless Elasticache)

Redisson version

3.21

Redisson configuration

@Configuration
@EnableRedisRepositories(
    enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.OFF,
    keyspaceNotificationsConfigParameter = ""
)
internal class RedisConnectionFactoryConfig(
    private val springDataProperties: SpringDataProperties,
    private val profileProperties: ProfileProperties
) {
// REDDISSON
@Bean
fun redissonClient(): RedissonClient {
    val config = Config()

    if (profileProperties.active == "prod") {

        // Use cluster server mode configuration for production
        val clusterConfig = config.useClusterServers()
            .addNodeAddress(
                "rediss://${springDataProperties.redis.host}:${springDataProperties.redis.port}"
            )
            .setScanInterval(2000) // Cluster state scan interval in milliseconds
            .setTimeout(60000) // Command timeout
            .setRetryAttempts(3) // Retry attempts for failed commands
            .setRetryInterval(1500) // 1.5 seconds between retry attempts
            .setConnectTimeout(60000) // 60 seconds
            .setMasterConnectionPoolSize(100)
            .setMasterConnectionMinimumIdleSize(10)
            .setIdleConnectionTimeout(10000)
            .setSubscriptionsPerConnection(5)
            .setSslEnableEndpointIdentification(true) // enable endpoint identification for SSL

        val redisPassword = springDataProperties.redis.password
        if (redisPassword.isNotBlank()) {
            clusterConfig.setPassword(redisPassword)
        }

        // Additional SSL configuration if needed
        clusterConfig
            .setSslTruststore(null) // Replace with actual truststore file path if necessary
            .setSslKeystore(null) // Replace with actual keystore file path if necessary
    } else {

        // Use single server mode configuration for non-production
        val singleServerConfig = config.useSingleServer()
            .setAddress("redis://${springDataProperties.redis.host}:${springDataProperties.redis.port}")
            .setTimeout(60000) // Command timeout
            .setRetryAttempts(3) // Retry attempts for failed commands
            .setRetryInterval(1500) // 1.5 seconds between retry attempts
            .setConnectTimeout(60000) // 60 seconds
            .setConnectionPoolSize(100)
            .setConnectionMinimumIdleSize(10)
            .setIdleConnectionTimeout(10000) // 10 seconds
            .setSubscriptionsPerConnection(5)
            .setSslEnableEndpointIdentification(false) // disable endpoint identification for SSL

        val redisPassword = springDataProperties.redis.password
        if (redisPassword.isNotBlank()) {
            singleServerConfig.setPassword(redisPassword)
        }

        // Additional SSL configuration if needed
        singleServerConfig
            .setSslTruststore(null) // Replace with actual truststore file if needed
            .setSslKeystore(null) // Replace with actual keystore file if needed
    }

    // Return the configured Redisson client
    return Redisson.create(config)
}

// reactive RedisConnectionFactory for key expiration event handling
@Bean
@Primary
fun reactiveRedisConnectionFactory(redissonClient: RedissonClient): ReactiveRedisConnectionFactory {
    return RedissonConnectionFactory(redissonClient)
}

}

What is the Expected behavior?

On my local machine, everything works:

2024-10-20T01:05:13.135+01:00  INFO 3050 --- [BFFApplication] [   redisson-4-6] c.f.b.a.sessions.SessionListenerConfig   : Session created: BFF-3f3400dd34c22190cebfc18ff8eda96e7d248ea3ef10e52e8a95c4df41a37e14-871786289805479-77135-ffb95668-1a41-46bc-aefe-ad2588b8f759
2024-10-20T01:05:14.068+01:00  INFO 3050 --- [BFFApplication] [ctor-http-nio-2] .b.a.r.t.CustomServerCsrfTokenRepository : Loading CSRF token
2024-10-20T01:05:14.068+01:00  INFO 3050 --- [BFFApplication] [ctor-http-nio-2] .b.a.r.t.CustomServerCsrfTokenRepository : Generating CSRF token
2024-10-20T01:05:14.068+01:00  INFO 3050 --- [BFFApplication] [ctor-http-nio-2] c.f.b.a.h.c.SPACsrfTokenRequestHandler   : Handling CSRF token: MonoSwitchIfEmpty
2024-10-20T01:05:14.069+01:00  INFO 3050 --- [BFFApplication] [ctor-http-nio-2] f.b.a.r.s.RedisSecurityContextRepository : REMOVING AUTHORIZATION REQUEST
2024-10-20T01:05:14.081+01:00  INFO 3050 --- [BFFApplication] [sson-netty-3-19] f.b.a.r.s.RedisSecurityContextRepository : session id: BFF-3f3400dd34c22190cebfc18ff8eda96e7d248ea3ef10e52e8a95c4df41a37e14-871786289805479-77135-ffb95668-1a41-46bc-aefe-ad2588b8f759
2024-10-20T01:05:14.081+01:00  INFO 3050 --- [BFFApplication] [sson-netty-3-19] f.b.a.r.s.RedisSecurityContextRepository : session attributes: [AUTHORIZATION_REQUEST]
2024-10-20T01:05:14.094+01:00  INFO 3050 --- [BFFApplication] [sson-netty-3-19] f.b.a.r.s.RedisSecurityContextRepository : Successfully deserialized Authorization Request: org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest@59dfcd58
2024-10-20T01:05:14.094+01:00  INFO 3050 --- [BFFApplication] [sson-netty-3-19] f.b.a.r.s.RedisSecurityContextRepository : state: p3mFNsKg906_bYGYPa8UFjbMnq863CTH4RiAvlqMdoU= authorization request state: p3mFNsKg906_bYGYPa8UFjbMnq863CTH4RiAvlqMdoU=

In AWS Elasticache, the session ID logged, is different to the one created, so I get a state mismatch.

2024-10-20T00:06:30.399Z INFO 1 --- [BFFApplication] [ redisson-4-8] c.f.b.a.sessions.SessionListenerConfig : Session created: BFF-e4ef60281b13352e0d97531e04a928638da3cbdab75d4f51521868c33cce94a4-1062080912734-88788-8677d299-55a9-4b22-a3c1-4d3dad442670

2024-10-20T00:06:31.284Z INFO 1 --- [BFFApplication] [or-http-epoll-2] .b.a.r.t.CustomServerCsrfTokenRepository : Loading CSRF token

2024-10-20T00:06:31.286Z INFO 1 --- [BFFApplication] [or-http-epoll-2] c.f.b.a.h.c.SPACsrfTokenRequestHandler : Handling CSRF token: MonoSwitchIfEmpty

2024-10-20T00:06:31.285Z INFO 1 --- [BFFApplication] [or-http-epoll-2] .b.a.r.t.CustomServerCsrfTokenRepository : Generating CSRF token

2024-10-20T00:06:31.287Z INFO 1 --- [BFFApplication] [or-http-epoll-2] f.b.a.r.s.RedisSecurityContextRepository : REMOVING AUTHORIZATION REQUEST

2024-10-20T00:06:31.291Z INFO 1 --- [BFFApplication] [ parallel-1] f.b.a.r.s.RedisSecurityContextRepository : State in request does not match Authorization Request state in Session: org.springframework.security.config.web.server.ServerHttpSecurity$OAuth2LoginSpec$OidcSessionRegistryWebFilter$OidcSessionRegistryServerWebExchange$OidcSessionRegistryWebSession@12f7d381

2024-10-20T00:06:31.291Z INFO 1 --- [BFFApplication] [ parallel-1] f.b.a.r.s.RedisSecurityContextRepository : state: BVnmHIYnFoeWIem7FirrWiCnOUH3VZzsleS3kZEBfwE= authorization request state: null

2024-10-20T00:06:31.291Z WARN 1 --- [BFFApplication] [ parallel-1] f.b.a.r.s.RedisSecurityContextRepository : No Authorization Request found in WebSession

2024-10-20T00:06:31.291Z INFO 1 --- [BFFApplication] [ parallel-1] f.b.a.r.s.RedisSecurityContextRepository : session attributes: []

2024-10-20T00:06:31.291Z INFO 1 --- [BFFApplication] [ parallel-1] f.b.a.r.s.RedisSecurityContextRepository : session id: BFF-a457e79d6b6e6cde87dc25ce5711c683124bc4d179256e3ad60f52114e8b9902-1293912647308-21782-b256296b-fbd0-4270-bba3-7fc96cc4ddd2

2024-10-20T00:06:31.293Z INFO 1 --- [BFFApplication] [ parallel-1] OAuth2ServerAuthenticationFailureHandler : ON FAILURE REDIRECT URI: /

Here is the exact same code for the authrozation request function I have:

@Repository
internal class RedisAuthorizationRequestRepository(
    private val redisSerialiserConfig: RedisSerialiserConfig
) : ServerAuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    private val logger = LoggerFactory.getLogger(RedisSecurityContextRepository::class.java)
    private val springAuthorizationRequestAttrName: String = "AUTHORIZATION_REQUEST"

    override fun saveAuthorizationRequest(
        authorizationRequest: OAuth2AuthorizationRequest?,
        exchange: ServerWebExchange
    ): Mono<Void> {
        logger.info("SAVING AUTHORIZATION REQUEST")

        return exchange.session
            .doOnNext { session ->
                if (authorizationRequest?.state == null) {
                    logger.info("Authorization Request State cannot be empty")
                } else {
                    session.attributes[springAuthorizationRequestAttrName] = authorizationRequest
                    logger.info("Authorization Request state: ${authorizationRequest.state}")
                    logger.info("Saved Authorization Request $authorizationRequest in WebSession: $session")
                }
            }.then()
    }

    override fun loadAuthorizationRequest(exchange: ServerWebExchange): Mono<OAuth2AuthorizationRequest?> {
        logger.info("LOADING AUTHORIZATION REQUEST")
        val state = getURIStateParameter(exchange) ?: return Mono.empty()
        return exchange.session
            .flatMap { session ->
                val authorizationRequest = getAuthorizationRequest(session)
                if (state == authorizationRequest?.state) {
                    logger.info("Loading authorization request")
                    Mono.just(authorizationRequest)
                } else {
                    logger.warn("State in request does not match Authorization Request state in Session: $session")
                    Mono.empty()
                }
            }
    }

    override fun removeAuthorizationRequest(exchange: ServerWebExchange): Mono<OAuth2AuthorizationRequest?> {
        logger.info("REMOVING AUTHORIZATION REQUEST")
        val state = getURIStateParameter(exchange) ?: return Mono.empty()
        return exchange.session
            .flatMap { session ->
                val authorizationRequest = getAuthorizationRequest(session)
                logger.info("state: $state authorization request state: ${authorizationRequest?.state}")
                if (state == authorizationRequest?.state) {
                    session.attributes.remove(this.springAuthorizationRequestAttrName)
                    logger.info("Removed authorization request")
                    Mono.just(authorizationRequest)
                } else {
                    logger.info("State in request does not match Authorization Request state in Session: $session")
                    Mono.empty()
                }
            }
    }

    // Helper methods
    private fun getURIStateParameter(exchange: ServerWebExchange): String? {
        requireNotNull(exchange) { "exchange cannot be null" }
        return exchange.request.queryParams[OAuth2ParameterNames.STATE]?.firstOrNull()
    }

    private fun getAuthorizationRequest(session: WebSession): OAuth2AuthorizationRequest? {
        Assert.notNull(session, "session cannot be null")
        logger.info("session id: ${session.id}")
        logger.info("session attributes: ${session.attributes.keys}")
        val authRequestAttr = session.getAttribute<Map<String, Any>>(springAuthorizationRequestAttrName)
        if (authRequestAttr != null) {
            // Deserialize from Map to OAuth2AuthorizationRequest
            val map = authRequestAttr as? Map<String, Any>
            try {
                val authorizationRequest = redisSerialiserConfig.redisObjectMapper()
                    .convertValue(map, OAuth2AuthorizationRequest::class.java)
                logger.info("Successfully deserialized Authorization Request: $authorizationRequest")
                return authorizationRequest
            } catch (e: Exception) {
                logger.error("Error deserializing Authorization Request: ${e.message}", e)
                return null
            }
        } else {
            logger.warn("No Authorization Request found in WebSession")
            return null
        }
    }

}

I am also using Redisson rather than Lettuce on AWS Elasticache (as Lettuce kept giving a crosslot error: https://github.com/redisson/redisson/issues/3992)

Also neither work Redisson or Lettuce, with Spring work on the new AWS Serverless Elasticache, as they both give 'psubscribe' errors: https://github.com/spring-projects/spring-data-redis/issues/2815

Why is AWS Elasticache returning a different Session ID, and hence failing the authentication flow?

What is the Actual behavior?

Session ID should be the same, just like with my local machine testing using the cache on Redis.com

Additional information

No response

dreamstar-enterprises commented 1 month ago

UPDATE:

I think the problem is that for some reason when I get redirected from Auth0 back to my App, the request does not send the original session cookie:

AWS

enter image description here

LocalHost

enter image description here

I have set Cookie SameSite to Lax in both cases. Does anyone know why the browser is not adding the Session Cookie when hosted on AWS?

dreamstar-enterprises commented 1 month ago

I had Cookie secure set to true, when it should have been false, as my AWS even through aims to be production, is still on the http protocol (debugging!)