diff --git a/Spring BFF/bff/BFFApplication.kt b/Spring BFF/bff/BFFApplication.kt new file mode 100644 index 0000000..eada5ba --- /dev/null +++ b/Spring BFF/bff/BFFApplication.kt @@ -0,0 +1,14 @@ +package com.example.bff + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class BFFApplication + +fun main(args: Array) { + + // run SpringBoot application + runApplication(*args) + +} diff --git a/Spring BFF/bff/api/controllers/BackChannelLogoutController.kt b/Spring BFF/bff/api/controllers/BackChannelLogoutController.kt new file mode 100644 index 0000000..3c4b481 --- /dev/null +++ b/Spring BFF/bff/api/controllers/BackChannelLogoutController.kt @@ -0,0 +1,31 @@ +package com.example.bff.api.controllers + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.RequestBody + +/**********************************************************************************************************************/ +/**************************************************** CONTROLLER ******************************************************/ +/**********************************************************************************************************************/ + +@RestController +@RequestMapping("/logout/connect/back-channel/in-house-auth-server") +class BackChannelLogoutController { + + @PostMapping + fun handleLogout(@RequestBody logoutRequest: Map): ResponseEntity { + // Handle the logout notification here + // For example, invalidate user sessions or perform other cleanup + + println("Received back-channel logout request: $logoutRequest") + + // Return HTTP 200 OK to acknowledge receipt of the logout request + return ResponseEntity.ok().build() + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/api/controllers/GatewayFallback.kt b/Spring BFF/bff/api/controllers/GatewayFallback.kt new file mode 100644 index 0000000..544c832 --- /dev/null +++ b/Spring BFF/bff/api/controllers/GatewayFallback.kt @@ -0,0 +1,21 @@ +package com.example.bff.api.controllers + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/**********************************************************************************************************************/ +/**************************************************** CONTROLLER ******************************************************/ +/**********************************************************************************************************************/ + +@RestController +internal class FallbackController { + + @GetMapping("/fallback") + fun fallback(): Map { + return mapOf("message" to "Gateway fallback occured") + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/api/controllers/LoginOptionsController.kt b/Spring BFF/bff/api/controllers/LoginOptionsController.kt new file mode 100644 index 0000000..a489976 --- /dev/null +++ b/Spring BFF/bff/api/controllers/LoginOptionsController.kt @@ -0,0 +1,69 @@ +package com.example.bff.api.controllers + +import com.c4_soft.springaddons.security.oidc.starter.reactive.client.SpringAddonsOauth2ServerRedirectStrategy +import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties +import com.example.bff.props.ServerProperties +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.NotEmpty +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.MediaType +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter +import org.springframework.security.oauth2.core.AuthorizationGrantType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import reactor.core.publisher.Mono +import java.net.URI + +/**********************************************************************************************************************/ +/**************************************************** CONTROLLER ******************************************************/ +/**********************************************************************************************************************/ + +@RestController +@RequestMapping("/login-options") +internal class LoginOptionsController( + private val serverProperties: ServerProperties, + private val clientRegistrationRepository: ReactiveClientRegistrationRepository, +) { + + private var loginOptions: List? = null + + @GetMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) + fun getLoginOptions(): Mono> { + if (loginOptions == null || loginOptions!!.isEmpty()) { + + val clientAuthority = URI.create(serverProperties.reverseProxyUri).authority + val clientRegistrations = (clientRegistrationRepository as? InMemoryReactiveClientRegistrationRepository)?.toList() + ?: emptyList() + + loginOptions = clientRegistrations + .filter { it.authorizationGrantType == AuthorizationGrantType.AUTHORIZATION_CODE } + .map { registration -> + // client name + val label = registration.clientName + // internal oauth2 request redirection filter + val oauth2Redirection = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI + // internal endpoint that redirects to the auth server + val loginUri = "${serverProperties.bffUri}$oauth2Redirection/${registration.registrationId}" + // checks if issuer authority and reverse proxy authority are the same + val providerIssuerAuthority = registration.providerDetails.issuerUri?.toString() + ?.let { URI.create(it).authority } + LoginOptionDto(label, loginUri, clientAuthority == providerIssuerAuthority) + } + } + + return Mono.just(loginOptions!!) + } + + data class LoginOptionDto( + @field:NotEmpty val label: String, + @field:NotEmpty val loginUri: String, + @get:JsonProperty("isSameAuthority") val isSameAuthority: Boolean + ) +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/BffSecurityConfig.kt b/Spring BFF/bff/auth/BffSecurityConfig.kt new file mode 100644 index 0000000..c85bf85 --- /dev/null +++ b/Spring BFF/bff/auth/BffSecurityConfig.kt @@ -0,0 +1,221 @@ +package com.example.bff.auth + +import com.example.bff.auth.configurations.ClientConfigurationSupport +import com.example.bff.auth.configurations.postprocessors.ClientAuthorizeExchangeSpecPostProcessor +import com.example.bff.auth.configurations.postprocessors.ClientReactiveHttpSecurityPostProcessor +import com.example.bff.auth.cors.CORSConfig +import com.example.bff.auth.csrf.CsrfProtectionMatcher +import com.example.bff.auth.filters.csrf.CsrfWebCookieFilter +import com.example.bff.auth.handlers.DelegatingAuthenticationSuccessHandler +import com.example.bff.auth.handlers.csrf.SPACsrfTokenRequestHandler +import com.example.bff.auth.handlers.oauth2.OAuth2ServerAuthenticationFailureHandler +import com.example.bff.auth.handlers.oauth2.OAuth2ServerLogoutSuccessHandler +import com.example.bff.auth.handlers.oauth2.PreAuthorizationCodeServerRedirectStrategy +import com.example.bff.auth.handlers.sessions.CustomMaximumSessionsExceededHandler +import com.example.bff.auth.repositories.RedisAuthorizationRequestRepository +import com.example.bff.auth.repositories.authclients.RedisReactiveOAuth2AuthorizedClientService +import com.example.bff.auth.repositories.authclients.RedisServerOAuth2AuthorizedClientRepository +import com.example.bff.auth.repositories.securitycontext.RedisSecurityContextRepository +import com.example.bff.auth.requestcache.ReactiveRequestCache +import com.example.bff.props.* +import com.example.bff.props.ServerProperties +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.security.config.annotation.web.builders.WebSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.SecurityWebFiltersOrder +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity.LogoutSpec +import org.springframework.security.core.session.ReactiveSessionRegistry +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizationRequestResolver +import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler +import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository +import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher +import org.springframework.session.data.redis.config.annotation.web.server.EnableRedisIndexedWebSession +import org.springframework.web.util.UriComponentsBuilder +import java.net.URI +import java.util.* +import org.springframework.boot.autoconfigure.web.ServerProperties as NettyServerProperties + +/**********************************************************************************************************************/ +/*********************************************** DEFAULT SECURITY CONFIGURATION ***************************************/ +/**********************************************************************************************************************/ + +@Configuration +@EnableWebFluxSecurity +@EnableRedisIndexedWebSession +@Order(Ordered.LOWEST_PRECEDENCE - 1) +internal class BffSecurityConfig ( + private val serverProperties: ServerProperties, +) { + + @Bean + fun webSecurityCustomizer(): WebSecurityCustomizer { + return WebSecurityCustomizer { web: WebSecurity -> + web.debug(false) + .ignoring() + .requestMatchers("/favicon.ico") + } + } + + @Bean + fun clientSecurityFilterChain( + http: ServerHttpSecurity, + nettyServerProperties: NettyServerProperties, + + loginProperties: LoginProperties, + + serverCsrfTokenRepository: ServerCsrfTokenRepository, + spaCsrfTokenRequestHandler: SPACsrfTokenRequestHandler, + csrfProtectionMatcher: CsrfProtectionMatcher, + csrfCookieWebFilter: CsrfWebCookieFilter, + + corsConfig: CORSConfig, + corsProperties: CorsProperties, + + reactiveRequestCache: ReactiveRequestCache, + reactiveSessionRegistry: ReactiveSessionRegistry, + maximumSessionsExceededHandler: CustomMaximumSessionsExceededHandler, + + authenticationProperties: AuthenticationProperties, + authorizationProperties: AuthorizationProperties, + + oauthAuthorizationRequestResolver: ServerOAuth2AuthorizationRequestResolver, + redisAuthorizationRequestRepository: RedisAuthorizationRequestRepository, + preAuthorizationCodeRedirectStrategy: PreAuthorizationCodeServerRedirectStrategy, + delegatingAuthenticationSuccessHandler: DelegatingAuthenticationSuccessHandler, + oauth2ServerAuthenticationFailureHandler: OAuth2ServerAuthenticationFailureHandler, + + redisSecurityContextRepository: RedisSecurityContextRepository, + reactiveClientRegistrationRepository: ReactiveClientRegistrationRepository, + redisAuthorizedClientRepository: RedisServerOAuth2AuthorizedClientRepository, + redisReactiveOAuth2AuthorizedClientService: RedisReactiveOAuth2AuthorizedClientService, + + logoutProperties: LogoutProperties, + logoutHandler: Optional, + logoutSuccessHandler: OAuth2ServerLogoutSuccessHandler, + backChannelLogoutProperties: BackChannelLogoutProperties, + + authorizePostProcessor: ClientAuthorizeExchangeSpecPostProcessor, + httpPostProcessor: ClientReactiveHttpSecurityPostProcessor, + ): SecurityWebFilterChain { + + // initialise logger + val log = LoggerFactory.getLogger(SecurityWebFilterChain::class.java) + + // apply security matchers to this filter chain + val clientRoutes: List = authenticationProperties + .securityMatchers + .map { PathPatternParserServerWebExchangeMatcher(it) } + log.info( + "Applying client OAuth2 configuration for: {}", + authenticationProperties.securityMatchers + ) + http.securityMatcher(OrServerWebExchangeMatcher(clientRoutes)) + + // unauthenticated exception handler + loginProperties.LOGIN_URL.let { loginPath -> + http.exceptionHandling { exceptionHandling -> + exceptionHandling.authenticationEntryPoint( + RedirectServerAuthenticationEntryPoint( + UriComponentsBuilder.fromUri( + URI.create(serverProperties.clientUri) + ).path(loginPath).build().toString() + ) + ) + } + } + + // enable csrf + http.csrf { csrf -> + csrf.csrfTokenRepository(serverCsrfTokenRepository) + csrf.csrfTokenRequestHandler(spaCsrfTokenRequestHandler) + csrf.requireCsrfProtectionMatcher(csrfProtectionMatcher) + } + + // configure cors + http.cors { cors -> + cors.configurationSource( + corsConfig.corsConfigurationSource() + ) + } + + // configure request cache + http.requestCache { cache -> + cache.requestCache(reactiveRequestCache) + } + + // session management + // this is also handled in the success handler, delegatingAuthenticationSuccessHandler + + // oauth2.0 client login + http.oauth2Login { oauth2 -> + oauth2.authorizationRequestResolver(oauthAuthorizationRequestResolver) + oauth2.authorizationRequestRepository(redisAuthorizationRequestRepository) + + oauth2.authorizationRedirectStrategy(preAuthorizationCodeRedirectStrategy) + oauth2.authenticationSuccessHandler(delegatingAuthenticationSuccessHandler) + oauth2.authenticationFailureHandler(oauth2ServerAuthenticationFailureHandler) + + oauth2.securityContextRepository(redisSecurityContextRepository) + oauth2.clientRegistrationRepository(reactiveClientRegistrationRepository) + oauth2.authorizedClientRepository(redisAuthorizedClientRepository) + oauth2.authorizedClientService(redisReactiveOAuth2AuthorizedClientService) + } + + // oauth2.0 client + http.oauth2Client {} + + // logout configuration (with relying-party initiated logout) + http.logout { logout: LogoutSpec -> + logoutHandler.ifPresent { handler: ServerLogoutHandler -> + logout.logoutHandler(handler) + } + logout.logoutSuccessHandler(logoutSuccessHandler) + } + + // oidc backchannel logout configuration + // automatically creates: /logout/connect/back-channel/{registrationId} + if(backChannelLogoutProperties.enabled) { + http.oidcLogout { logout -> + logout.backChannel { bc -> + bc.logoutUri(backChannelLogoutProperties.internalLogoutUri) + } +// logout.oidcSessionRegistry() +// logout.clientRegistrationRepository() + // what is ReactiveOidcSessionStrategy for? + // https://docs.spring.io/spring-security/reference/reactive/oauth2/login/logout.html#configure-provider-initiated-oidc-logout + } + } + + // other filters + // apply csrf filter after the logout handler + http.addFilterAfter(csrfCookieWebFilter, SecurityWebFiltersOrder.LOGOUT) + + // apply additional configuraitons + ClientConfigurationSupport.configureClient( + http, + nettyServerProperties, + corsProperties, + authorizationProperties, + authorizePostProcessor, + httpPostProcessor + ) + + return http.build() + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/configurations/ClientConfigurationSupport.kt b/Spring BFF/bff/auth/configurations/ClientConfigurationSupport.kt new file mode 100644 index 0000000..7d187a5 --- /dev/null +++ b/Spring BFF/bff/auth/configurations/ClientConfigurationSupport.kt @@ -0,0 +1,119 @@ +package com.example.bff.auth.configurations + +import com.example.bff.auth.configurations.postprocessors.ClientAuthorizeExchangeSpecPostProcessor +import com.example.bff.auth.configurations.postprocessors.ClientReactiveHttpSecurityPostProcessor +import com.example.bff.props.AuthorizationProperties +import com.example.bff.props.CorsProperties +import org.springframework.boot.autoconfigure.web.ServerProperties as NettyServerProperties +import org.springframework.http.HttpMethod +import org.springframework.security.config.Customizer +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository +import org.springframework.stereotype.Component + +/**********************************************************************************************************************/ +/*************************************************** CLIENT CONFIGURATION *********************************************/ +/**********************************************************************************************************************/ + +/** + * Sets additional configurations for the main OAuth2 Client Security chain + */ +@Component +internal class ClientConfigurationSupport private constructor() { + + companion object { + + @JvmStatic + // configure OAuth 2.0 client security filter + fun configureClient( + http: ServerHttpSecurity, + nettyServerProperties: NettyServerProperties, + corsProperties: CorsProperties, + authorizationProperties: AuthorizationProperties, + authorizePostProcessor: ClientAuthorizeExchangeSpecPostProcessor, + httpPostProcessor: ClientReactiveHttpSecurityPostProcessor + ): ServerHttpSecurity { + + // wrap corsProperties into a List + val corsProps: List = listOf(corsProperties) + + // configure state (default is false) + configureState(http, false) + + // configure access + configureAccess(http, authorizationProperties.permitAll, corsProps) + + // configure ssl + if (nettyServerProperties.ssl != null && nettyServerProperties.ssl.isEnabled) { + http.redirectToHttps { } + } + + // security rules for all paths that are not listed in "permit-all" + http.authorizeExchange { authorizeExchangeSpec -> + authorizePostProcessor.authorizeHttpRequests(authorizeExchangeSpec) + } + + // hook to override all or part of HttpSecurity auto-configuration. + httpPostProcessor.process(http) + + return http + } + + @JvmStatic + // function for configuring authorization access + fun configureAccess( + http: ServerHttpSecurity, + permitAll: List, + corsProperties: List + ): ServerHttpSecurity { + + // allow access for prefetch request - OPTIONS? + val permittedCorsOptions = corsProperties + .filter { cors -> + (cors.allowedMethods.contains("*") || cors.allowedMethods.contains("OPTIONS")) && + !cors.disableAnonymousOptions + } + .map { it.path } + + // configure with defaults + if (permitAll.isNotEmpty() || permittedCorsOptions.isNotEmpty()) { + http.anonymous(Customizer.withDefaults()) + } + + // set permitAll path matchers + if (permitAll.isNotEmpty()) { + http.authorizeExchange { authorizeExchange -> + authorizeExchange.pathMatchers( + *permitAll.toTypedArray() + ).permitAll() + } + } + + // set CORS path matchers + if (permittedCorsOptions.isNotEmpty()) { + http.authorizeExchange { authorizeExchange -> + authorizeExchange.pathMatchers( + HttpMethod.OPTIONS, + *permittedCorsOptions.toTypedArray() + ).permitAll() + } + } + + return http + } + + @JvmStatic + // function for configuring state + fun configureState(http: ServerHttpSecurity, isStateless: Boolean): ServerHttpSecurity { + if (isStateless) { + http.securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) + } + + return http + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/configurations/postprocessors/PostProcessorsConfig.kt b/Spring BFF/bff/auth/configurations/postprocessors/PostProcessorsConfig.kt new file mode 100644 index 0000000..96e6dcc --- /dev/null +++ b/Spring BFF/bff/auth/configurations/postprocessors/PostProcessorsConfig.kt @@ -0,0 +1,70 @@ +package com.example.bff.auth.configurations.postprocessors + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity.AuthorizeExchangeSpec + +/**********************************************************************************************************************/ +/*************************************************** CONFIGURATION ****************************************************/ +/**********************************************************************************************************************/ + +@Configuration +internal class PostProcessorsConfig { + + /** + * Hook to override security rules for all paths that are not listed in + * "permit-all". Default is isAuthenticated(). + * + * @return a hook to override security rules for all paths that are not listed in + * "permit-all". Default is isAuthenticated(). + * + * Fine tunes access control from java configuration. It applies to all routes not listed in "permit-all" + * property configuration. Default requires users to be authenticated. + */ + @ConditionalOnMissingBean + @Bean + internal fun clientAuthorizePostProcessor(): ClientAuthorizeExchangeSpecPostProcessor { + return ClientAuthorizeExchangeSpecPostProcessor { + spec: ServerHttpSecurity.AuthorizeExchangeSpec -> + spec.anyExchange().authenticated() + } + } + + /** + * Hook to override all or part of HttpSecurity auto-configuration. + * + * @return a hook to override all or part of HttpSecurity auto-configuration. + * Overrides anything from above auto-configuration. + * It is called just before the security filter-chain is returned. Default is a no-op. + */ + @ConditionalOnMissingBean + @Bean + internal fun clientHttpPostProcessor(): ClientReactiveHttpSecurityPostProcessor { + return ClientReactiveHttpSecurityPostProcessor { + serverHttpSecurity: ServerHttpSecurity -> + serverHttpSecurity + } + } +} + +/**********************************************************************************************************************/ +/***************************************************** INTERFACES *****************************************************/ +/**********************************************************************************************************************/ + +internal fun interface ClientAuthorizeExchangeSpecPostProcessor : AuthorizeExchangeSpecPostProcessor + +internal fun interface ClientReactiveHttpSecurityPostProcessor : ReactiveHttpSecurityPostProcessor + +interface AuthorizeExchangeSpecPostProcessor { + fun authorizeHttpRequests(spec: AuthorizeExchangeSpec): AuthorizeExchangeSpec? +} + +interface ReactiveHttpSecurityPostProcessor { + fun process(serverHttpSecurity: ServerHttpSecurity): ServerHttpSecurity +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/connections/RedisConnectionFactoryConfig.kt b/Spring BFF/bff/auth/connections/RedisConnectionFactoryConfig.kt new file mode 100644 index 0000000..284b22c --- /dev/null +++ b/Spring BFF/bff/auth/connections/RedisConnectionFactoryConfig.kt @@ -0,0 +1,100 @@ +package com.example.bff.auth.redis + +import com.example.bff.props.SpringDataProperties +import io.lettuce.core.ClientOptions +import io.lettuce.core.SocketOptions +import io.lettuce.core.resource.DefaultClientResources +import org.apache.commons.pool2.impl.GenericObjectPoolConfig +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory +import org.springframework.data.redis.connection.RedisPassword +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory +import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration +import java.time.Duration + +/**********************************************************************************************************************/ +/************************************************ CONNECTION FACTORY **************************************************/ +/**********************************************************************************************************************/ + +// see here for more: +// https://docs.spring.io/spring-session/reference/web-session.html#websession-redis +// https://docs.spring.io/spring-session/reference/configuration/reactive-redis-indexed.html + +/** + * Establishes a Connection (factory) with Redis + */ +@Configuration +internal class RedisConnectionFactoryConfig( + private val springDataProperties: SpringDataProperties +) { + + @Bean + @Primary + fun reactiveRedisConnectionFactory(): ReactiveRedisConnectionFactory { + + // configure Redis standalone configuration + val config = RedisStandaloneConfiguration() + config.hostName = springDataProperties.redis.host + config.port = springDataProperties.redis.port + config.setPassword(RedisPassword.of(springDataProperties.redis.password)) + + // create socket options + val socketOptions = SocketOptions.builder() + .keepAlive(true) + // time to wait for connection to be established, before considering it as a failed connection + .connectTimeout(Duration.ofSeconds(60)) + .build() + + // create client options + val clientOptions = ClientOptions.builder() + .autoReconnect(true) + .pingBeforeActivateConnection(true) + .socketOptions(socketOptions) + .build() + + // customize thread pool size + val clientResources = DefaultClientResources.builder() + .ioThreadPoolSize(4) + .computationThreadPoolSize(4) + .build() + + // create Lettuce client configuration with authentication details + val clientConfig = LettucePoolingClientConfiguration.builder() + // maximum time allowed for a Redis command to execute before the operation is considered timed out. + .commandTimeout(Duration.ofSeconds(60)) + .clientResources(clientResources) + .clientOptions(clientOptions) + .poolConfig(buildLettucePoolConfig()) + .useSsl() + .build() + + // create Lettuce connection factory + return LettuceConnectionFactory(config, clientConfig).apply { + afterPropertiesSet() + } + } + + // configure connection pool settings + protected fun buildLettucePoolConfig(): GenericObjectPoolConfig { + val poolConfig = GenericObjectPoolConfig() + poolConfig.maxTotal = 100 + poolConfig.maxIdle = 50 + poolConfig.minIdle = 10 + poolConfig.setMaxWait(Duration.ofSeconds(120)) + poolConfig.timeBetweenEvictionRuns = Duration.ofSeconds(120) + poolConfig.minEvictableIdleTime = Duration.ofMinutes(5) + poolConfig.testOnBorrow = true + poolConfig.testWhileIdle = true + poolConfig.testOnReturn = true + return poolConfig + } + +} + + +///**********************************************************************************************************************/ +///**************************************************** END OF KOTLIN ***************************************************/ +///**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/cookies/CookiesSessionConfig.kt b/Spring BFF/bff/auth/cookies/CookiesSessionConfig.kt new file mode 100644 index 0000000..94b1366 --- /dev/null +++ b/Spring BFF/bff/auth/cookies/CookiesSessionConfig.kt @@ -0,0 +1,41 @@ +package com.example.bff.auth.cookies + +import com.example.bff.props.SessionProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.server.session.CookieWebSessionIdResolver + +/**********************************************************************************************************************/ +/******************************************************* CONFIGURATION ************************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/common.html#custom-cookie-in-webflux + +/** + * Configures the Session Cookie Properties + */ +@Configuration +internal class CookiesSessionConfig( + private val sessionProperties: SessionProperties +) { + + @Bean + fun cookieWebSessionIdResolver(): CookieWebSessionIdResolver { + return CookieWebSessionIdResolver().apply { + setCookieName(sessionProperties.SESSION_COOKIE_NAME) + addCookieInitializer { cookie -> + cookie.httpOnly(sessionProperties.SESSION_COOKIE_HTTP_ONLY) + cookie.secure(sessionProperties.SESSION_COOKIE_SECURE) + cookie.sameSite(sessionProperties.SESSION_COOKIE_SAME_SITE) + cookie.maxAge(sessionProperties.SESSION_COOKIE_MAX_AGE) + cookie.path(sessionProperties.SESSION_COOKIE_PATH) + } + } + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/cors/CORSConfig.kt b/Spring BFF/bff/auth/cors/CORSConfig.kt new file mode 100644 index 0000000..9d9c37c --- /dev/null +++ b/Spring BFF/bff/auth/cors/CORSConfig.kt @@ -0,0 +1,85 @@ +package com.example.bff.auth.cors + +import com.example.bff.props.CorsProperties +import com.example.bff.props.CsrfProperties +import com.example.bff.props.ServerProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.reactive.CorsConfigurationSource +import org.springframework.web.cors.reactive.CorsWebFilter +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource +import org.springframework.web.reactive.config.CorsRegistry +import org.springframework.web.reactive.config.WebFluxConfigurer + +/**********************************************************************************************************************/ +/**************************************************** CORS CONFIGURATION **********************************************/ +/**********************************************************************************************************************/ + +/** + * Configures all CORS Properties + */ +@Configuration +// applies to web browser clients only - not server-to-server +internal class CORSConfig ( + private val serverProperties : ServerProperties, + private val corsProperties: CorsProperties, + private val csrfProperties: CsrfProperties +){ + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val source = UrlBasedCorsConfigurationSource() + val config = CorsConfiguration().apply { + // ensure this matches the Angular app URI + allowedOrigins = corsProperties.allowedOriginPatterns + allowedMethods = corsProperties.allowedMethods + allowedHeaders = corsProperties.allowedHeaders + exposedHeaders = corsProperties.exposedHeaders + maxAge = corsProperties.maxAge + allowCredentials = corsProperties.allowCredentials + } + source.registerCorsConfiguration(corsProperties.path, config) + return source + } + + @Bean + fun corsFilter(): CorsWebFilter { + return CorsWebFilter(corsConfigurationSource()) + } + + @Bean + fun corsConfig(): WebFluxConfigurer { + return object : WebFluxConfigurer { + override fun addCorsMappings(registry: CorsRegistry) { + registry.addMapping(corsProperties.path) + .allowedOrigins(serverProperties.reverseProxyUri) + .allowedMethods( + HttpMethod.GET.name(), + HttpMethod.POST.name(), + HttpMethod.PUT.name(), + HttpMethod.PATCH.name(), + HttpMethod.DELETE.name(), + HttpMethod.OPTIONS.name(), + ) + .allowedHeaders( + HttpHeaders.CONTENT_TYPE, + HttpHeaders.AUTHORIZATION, + csrfProperties.CSRF_HEADER_NAME, + ) + .exposedHeaders( + HttpHeaders.CONTENT_TYPE, + HttpHeaders.AUTHORIZATION, + csrfProperties.CSRF_HEADER_NAME, + ) + .allowCredentials(true) + } + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/csrf/CsrfProtectionMatcher.kt b/Spring BFF/bff/auth/csrf/CsrfProtectionMatcher.kt new file mode 100644 index 0000000..3836efd --- /dev/null +++ b/Spring BFF/bff/auth/csrf/CsrfProtectionMatcher.kt @@ -0,0 +1,30 @@ +package com.example.bff.auth.csrf + +import org.springframework.context.annotation.Configuration +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/************************************************** REQUEST MATCHER ***************************************************/ +/**********************************************************************************************************************/ + +/** + * Matcher implementation is designed to apply CSRF protection only to non-GET requests. + * It will produce a match result for all requests EXCEPT FOR: GET requests + */ +@Configuration +internal class CsrfProtectionMatcher : ServerWebExchangeMatcher { + override fun matches(exchange: ServerWebExchange): Mono? { + val method = exchange.request.method.name().uppercase() + return if (method != "GET") { + ServerWebExchangeMatcher.MatchResult.match() + } else { + Mono.empty() + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/filters/csrf/CsrfWebCookieFilter.kt b/Spring BFF/bff/auth/filters/csrf/CsrfWebCookieFilter.kt new file mode 100644 index 0000000..2039803 --- /dev/null +++ b/Spring BFF/bff/auth/filters/csrf/CsrfWebCookieFilter.kt @@ -0,0 +1,36 @@ +package com.example.bff.auth.filters.csrf + +import org.springframework.security.web.server.csrf.CsrfToken +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilter +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/******************************************************* FILTER *******************************************************/ +/**********************************************************************************************************************/ + +// needed for SPAs according to this: +// https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa + +/* + * Ensures that the CSRF token is loaded and processed as part of the request lifecycle. + * By accessing csrfToken.token, the filter causes any deferred operations related to the token to be executed + */ +@Component +internal class CsrfWebCookieFilter : WebFilter { + + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + return exchange.getAttributeOrDefault(CsrfToken::class.java.name, Mono.empty()) + .doOnNext { csrfToken -> + // render the token value to a cookie by causing the deferred token to be loaded + csrfToken.token + } + .then(chain.filter(exchange)) + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/handlers/DelegatingAuthenticationSuccessHandler.kt b/Spring BFF/bff/auth/handlers/DelegatingAuthenticationSuccessHandler.kt new file mode 100644 index 0000000..aeeaafa --- /dev/null +++ b/Spring BFF/bff/auth/handlers/DelegatingAuthenticationSuccessHandler.kt @@ -0,0 +1,41 @@ +package com.example.bff.auth.handlers + +import com.example.bff.auth.handlers.oauth2.OAuth2ServerAuthenticationSuccessHandler +import com.example.bff.auth.handlers.sessions.CustomConcurrentSessionControlSuccessHandler +import org.springframework.security.core.Authentication +import org.springframework.security.web.server.WebFilterExchange +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/****************************************************** HANDLER *******************************************************/ +/**********************************************************************************************************************/ + +/** + * Coordinates multiple success handlers to ensure that all necessary actions are taken when authentication succeeds. + * It allows different authentication success strategies to be applied in sequence. + */ +@Component +internal class DelegatingAuthenticationSuccessHandler( + private val primaryHandler: CustomConcurrentSessionControlSuccessHandler, + private val secondaryHandler: OAuth2ServerAuthenticationSuccessHandler +) : ServerAuthenticationSuccessHandler { + + override fun onAuthenticationSuccess( + webFilterExchange: WebFilterExchange, + authentication: Authentication + ): Mono { + // delegate to the primary handler + return primaryHandler.onAuthenticationSuccess(webFilterExchange, authentication) + .then( + // delegate to the secondary handler + secondaryHandler.onAuthenticationSuccess(webFilterExchange, authentication) + ) + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/handlers/builders/OAuth2LogoutRequestUriBuilder.kt b/Spring BFF/bff/auth/handlers/builders/OAuth2LogoutRequestUriBuilder.kt new file mode 100644 index 0000000..26739c6 --- /dev/null +++ b/Spring BFF/bff/auth/handlers/builders/OAuth2LogoutRequestUriBuilder.kt @@ -0,0 +1,176 @@ +package com.example.bff.auth.handlers.builders + +import com.example.bff.props.LogoutProperties +import com.example.bff.props.OAuth2LogoutProperties +import com.example.bff.props.OAuth2RedirectionProperties +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.stereotype.Component +import org.springframework.web.util.UriComponentsBuilder +import java.net.URI +import java.nio.charset.StandardCharsets + +/**********************************************************************************************************************/ +/****************************************************** BUILDER *******************************************************/ +/**********************************************************************************************************************/ + +/** + * logoutRequestUriBuilder: builder for RP-Initiated Logout queries, taking configuration from properties for + * OIDC providers which do not strictly comply with the spec: logout URI not provided by OIDC conf or non-standard + * parameter names (Auth0 and Cognito are samples of such OPs) + * Overall, gets logout request URI (which may call the logout endpoint of the authentication provider) + * So, ensures that the logout request is properly constructed and redirects the user to the appropriate URI + * based on the OAuth2 and application-specific configurations. + */ + +@Component +internal class OAuth2LogoutRequestUriBuilder( + private val oauth2RedirectionProperties: OAuth2RedirectionProperties, + private val logoutProperties: LogoutProperties +) : LogoutRequestUriBuilder { + + companion object { + private const val OIDC_RP_INITIATED_LOGOUT_CONFIGURATION_ENTRY = "end_session_endpoint" + private const val OIDC_RP_INITIATED_LOGOUT_CLIENT_ID_REQUEST_PARAM = "client_id" + private const val OIDC_RP_INITIATED_LOGOUT_ID_TOKEN_HINT_REQUEST_PARAM = "id_token_hint" + private const val OIDC_RP_INITIATED_LOGOUT_POST_LOGOUT_URI_REQUEST_PARAM = "post_logout_redirect_uri" + } + + /*************************/ + /* MAIN FUNCTIONS */ + /*************************/ + + // if postLogout URI call this function + override fun getLogoutRequestUri( + clientRegistration: ClientRegistration, + idToken: String, + postLogoutUri: URI? + ): String? { + + // get client registration logout properties (if they exist) from custom OAuth2 Logout Hashmap + val logoutProps = oauth2RedirectionProperties.getLogoutProperties(clientRegistration.registrationId) + + // if logout properties exist and rp-initialied logout is not enabled, + // return postLogout URI (don't go to logout endpoint) + if (logoutProps?.rpInitiatedLogoutEnabled == false) { + return postLogoutUri?.toString()?.takeIf { it.isNotBlank() } + } + + // otherwise, go and get the logout endpoint on the auth server: + val logoutEndpointUri = getLogoutEndpointUri(logoutProps, clientRegistration) + ?: throw MisconfiguredProviderException(clientRegistration.registrationId) + + // create builder from logout endpoint + val builder = UriComponentsBuilder.fromUri(logoutEndpointUri) + + // add token hint request parameter to builder + getIdTokenHintRequestParam(logoutProps).let { idTokenHintParamName -> + if (idToken.isNotBlank()) { + builder.queryParam(idTokenHintParamName, idToken) + } + } + + // add client id request parameter to builder + getClientIdRequestParam(logoutProps).let { clientIdParamName -> + clientRegistration.clientId?.takeIf { it.isNotBlank() }?.let { clientId -> + builder.queryParam(clientIdParamName, clientId) + } + } + + // add post logout request parameter to builder + getPostLogoutUriRequestParam(logoutProps).let { postLogoutUriParamName -> + postLogoutUri?.toString()?.takeIf { it.isNotBlank() }?.let { uri -> + builder.queryParam(postLogoutUriParamName, uri) + } + } + + return builder.encode(StandardCharsets.UTF_8).build().toUriString() + } + + // if no postLogout URI exists then still call the main function above! + override fun getLogoutRequestUri( + clientRegistration: ClientRegistration, + idToken: String + ): String? { + return getLogoutRequestUri( + clientRegistration, + idToken, + logoutProperties.getPostLogoutRedirectUri() + ) + } + + /*************************/ + /* HELPER FUNCTIONS */ + /*************************/ + // get LogoutEndPoint URI + fun getLogoutEndpointUri( + logoutProps: OAuth2LogoutProperties?, + clientRegistration: ClientRegistration + ): URI? { + // if logout properties exist & rp-initiated logout is enabled, return OAuth2 property logout endpoint + return logoutProps?.let { + if (it.rpInitiatedLogoutEnabled) { + it.uri + } else { + null + } + } + // otherwise, if logout properties do not exist + // return standard OIDC logout endpoint (as defined in client registrations) + ?: run { + val oidcConfig = clientRegistration.providerDetails.configurationMetadata + oidcConfig[OIDC_RP_INITIATED_LOGOUT_CONFIGURATION_ENTRY]?.toString()?.let { URI.create(it) } + } + } + + // get logoutTokenHint request parameter + fun getIdTokenHintRequestParam( + logoutProps: OAuth2LogoutProperties? + ): String { + return logoutProps?.idTokenHintRequestParam ?: OIDC_RP_INITIATED_LOGOUT_ID_TOKEN_HINT_REQUEST_PARAM + } + + // get clientID request parameter + fun getClientIdRequestParam( + logoutProps: OAuth2LogoutProperties?): String { + return logoutProps?.clientIdRequestParam ?: OIDC_RP_INITIATED_LOGOUT_CLIENT_ID_REQUEST_PARAM + } + + // get postLogout URI request param + fun getPostLogoutUriRequestParam( + logoutProps: OAuth2LogoutProperties? + ): String { + return logoutProps?.postLogoutUriRequestParam ?: OIDC_RP_INITIATED_LOGOUT_POST_LOGOUT_URI_REQUEST_PARAM + } + + // misconfigured provider exception + internal class MisconfiguredProviderException(clientRegistrationId: String) : RuntimeException( + "OAuth2 client registration for $clientRegistrationId RP-Initiated Logout is misconfigured: it is neither OIDC compliant nor defined in spring-addons properties" + ) { + companion object { + private const val serialVersionUID = -6023478025541369262L + } + } + +} + +/**********************************************************************************************************************/ +/***************************************************** INTERFACES *****************************************************/ +/**********************************************************************************************************************/ + +internal interface LogoutRequestUriBuilder { + + fun getLogoutRequestUri( + clientRegistration: ClientRegistration, + idToken: String + ): String? + + fun getLogoutRequestUri( + clientRegistration: ClientRegistration, + idToken: String, + postLogoutUri: URI? + ): String? +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/handlers/csrf/CsrfTokenRequestHandler.kt b/Spring BFF/bff/auth/handlers/csrf/CsrfTokenRequestHandler.kt new file mode 100644 index 0000000..820c1ca --- /dev/null +++ b/Spring BFF/bff/auth/handlers/csrf/CsrfTokenRequestHandler.kt @@ -0,0 +1,66 @@ +package com.example.bff.auth.handlers.csrf + +import com.example.bff.props.CsrfProperties +import org.springframework.security.web.server.csrf.CsrfToken +import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestHandler +import org.springframework.security.web.server.csrf.XorServerCsrfTokenRequestAttributeHandler +import org.springframework.stereotype.Component +import org.springframework.util.StringUtils +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/****************************************************** HANDLER *******************************************************/ +/**********************************************************************************************************************/ + +// needed for SPAs according to this: +// https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript-spa + +/** + * ensures that CSRF tokens are managed appropriately depending on whether they are included in headers + * or request parameters and incorporates protection mechanisms against specific security threats like BREACH. + */ +@Component +internal class SPACsrfTokenRequestHandler( + private val csrfProperties: CsrfProperties +) : ServerCsrfTokenRequestHandler { + + private val delegate: ServerCsrfTokenRequestHandler + = XorServerCsrfTokenRequestAttributeHandler() + + override fun handle( + exchange: ServerWebExchange?, + csrfToken: Mono? + ) { + /* + * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of + * the CsrfToken when it is rendered in the response body. + */ + delegate.handle(exchange, csrfToken) + } + + override fun resolveCsrfTokenValue(exchange: ServerWebExchange, csrfToken: CsrfToken): Mono? { + /* + * If the request contains a request header, use CsrfTokenRequestAttributeHandler + * to resolve the CsrfToken. This applies when a single-page application includes + * the header value automatically, which was obtained via a cookie containing the + * raw CsrfToken. + */ + val headerToken = exchange.request.headers.getFirst(csrfProperties.CSRF_HEADER_NAME) + return if (StringUtils.hasText(headerToken)) { + super.resolveCsrfTokenValue(exchange, csrfToken) + } else { + /* + * In all other cases (e.g. if the request contains a request parameter), use + * XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies + * when a server-side rendered form includes the _csrf request parameter as a + * hidden input. + */ + delegate.resolveCsrfTokenValue(exchange, csrfToken) + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerAuthenticationFailureHandler.kt b/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerAuthenticationFailureHandler.kt new file mode 100644 index 0000000..87fc8b3 --- /dev/null +++ b/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerAuthenticationFailureHandler.kt @@ -0,0 +1,91 @@ +package com.example.bff.auth.handlers.oauth2 + +import com.example.bff.auth.redirects.OAuth2ServerRedirectStrategy +import com.example.bff.props.LoginProperties +import com.example.bff.props.OAuth2RedirectionProperties +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.server.WebFilterExchange +import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler +import org.springframework.stereotype.Component +import org.springframework.web.util.UriComponentsBuilder +import org.springframework.web.util.UriUtils +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +/**********************************************************************************************************************/ +/****************************************************** HANDLER *******************************************************/ +/**********************************************************************************************************************/ + +/** + * Provides a comprehensive solution for handling authentication failures in OAuth2 scenarios, offering user + * feedback through redirection and error messages. + */ +@Component +internal class OAuth2ServerAuthenticationFailureHandler( + oauth2RedirectionProperties: OAuth2RedirectionProperties, + private val loginProperties: LoginProperties +) : ServerAuthenticationFailureHandler { + + // construct default re-direct uri + private val defaultRedirectUri: URI = oauth2RedirectionProperties.getLoginErrorRedirectUri() + ?: URI.create("/") + + // construct the default redirect strategy + private val redirectStrategy: OAuth2ServerRedirectStrategy = OAuth2ServerRedirectStrategy( + oauth2RedirectionProperties.postAuthorizationCode + ) + + // override onSuccess function + override fun onAuthenticationFailure( + webFilterExchange: WebFilterExchange, + exception: AuthenticationException? + ): Mono? { + return webFilterExchange.exchange?.session?.flatMap { session -> + + // retrieve the post-login failure URI from session or use a default URI + val uriString = session.getAttributeOrDefault( + loginProperties.POST_AUTHENTICATION_FAILURE_URI_SESSION_ATTRIBUTE, + defaultRedirectUri.toString() + ) + + println("ON FAILURE REDIRECT URI: $uriString") + + // decode the exception message + val decodedMessageMono = Mono.fromCallable { + URLDecoder.decode(exception?.message ?: "unknown error", StandardCharsets.UTF_8.name()) + }.subscribeOn(Schedulers.boundedElastic()) + + + // add query param to uri + decodedMessageMono.flatMap { decodedMessage -> + val encodedMessage = UriUtils.encodePath( + decodedMessage, StandardCharsets.UTF_8.name() + ) + + // build the URI with the properly encoded query parameter + val uriWithQueryParam = UriComponentsBuilder.fromUri(URI.create(uriString)) + .queryParam( + loginProperties.POST_AUTHENTICATION_FAILURE_CAUSE_ATTRIBUTE, + encodedMessage + ).build(true) + .toUri() + + println("URI with Query Parameters: $uriWithQueryParam") + + // Apply the redirect and return Mono + redirectStrategy.sendRedirect( + webFilterExchange.exchange, + uriWithQueryParam + ) + } + } + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerAuthenticationSuccessHandler.kt b/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerAuthenticationSuccessHandler.kt new file mode 100644 index 0000000..09925f9 --- /dev/null +++ b/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerAuthenticationSuccessHandler.kt @@ -0,0 +1,62 @@ +package com.example.bff.auth.handlers.oauth2 + +import com.example.bff.auth.redirects.OAuth2ServerRedirectStrategy +import com.example.bff.props.LoginProperties +import com.example.bff.props.OAuth2RedirectionProperties +import org.springframework.security.core.Authentication +import org.springframework.security.web.server.WebFilterExchange +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import java.net.URI + +/**********************************************************************************************************************/ +/****************************************************** HANDLER *******************************************************/ +/**********************************************************************************************************************/ + +/** + * Provides a customizable and flexible way to handle successful OAuth2 authentication by redirecting users to + * appropriate URIs based on session data and configuration properties + */ +@Component +internal class OAuth2ServerAuthenticationSuccessHandler( + oauth2RedirectionProperties: OAuth2RedirectionProperties, + private val loginProperties: LoginProperties +) : ServerAuthenticationSuccessHandler { + + // construct default re-direct uri + private val defaultRedirectUri: URI = oauth2RedirectionProperties.getPostLoginRedirectUri() + ?: URI.create("/") + + // construct the post authorization redirect strategy + private val redirectStrategy: OAuth2ServerRedirectStrategy = OAuth2ServerRedirectStrategy( + oauth2RedirectionProperties.postAuthorizationCode + ) + + // override onSuccess function + override fun onAuthenticationSuccess( + webFilterExchange: WebFilterExchange, + authentication: Authentication + ): Mono { + return webFilterExchange.exchange.session.flatMap { session -> + + // retrieve the post-login success URI from session or use a default URI + val uriString = session.getAttributeOrDefault( + loginProperties.POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE, + defaultRedirectUri.toString() + ) + + println("ON SUCCESSFUL REDIRECT URI: $uriString") + + // apply the redirect and return Mono + redirectStrategy.sendRedirect( + webFilterExchange.exchange, + URI.create(uriString) + ) + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerLogoutSuccessHandler.kt b/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerLogoutSuccessHandler.kt new file mode 100644 index 0000000..c0174c2 --- /dev/null +++ b/Spring BFF/bff/auth/handlers/oauth2/OAuth2ServerLogoutSuccessHandler.kt @@ -0,0 +1,71 @@ +package com.example.bff.auth.handlers.oauth2 + +import com.example.bff.auth.handlers.builders.LogoutRequestUriBuilder +import com.example.bff.auth.redirects.OAuth2ServerRedirectStrategy +import com.example.bff.props.LogoutProperties +import com.example.bff.props.OAuth2RedirectionProperties +import org.springframework.security.core.Authentication +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.core.oidc.user.OidcUser +import org.springframework.security.web.server.WebFilterExchange +import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import java.net.URI + +/**********************************************************************************************************************/ +/****************************************************** HANDLER *******************************************************/ +/**********************************************************************************************************************/ + +/** + * Determines the redirection URI based on headers, query parameters, or a default value. + * Constructs the appropriate logout URI using client registration details and ID token. + * Uses OAuth2ServerRedirectStrategy to handle the actual redirection process + */ +@Component +internal class OAuth2ServerLogoutSuccessHandler( + private val uriBuilder: LogoutRequestUriBuilder, + private val clientRegistrationRepo: ReactiveClientRegistrationRepository, + private val logoutProperties: LogoutProperties, + oauth2RedirectionProperties: OAuth2RedirectionProperties, +) : ServerLogoutSuccessHandler { + + private val defaultPostLogoutUri: String? = logoutProperties.getPostLogoutRedirectUri()?.toString() + private val redirectStrategy = OAuth2ServerRedirectStrategy(oauth2RedirectionProperties.rpInitiatedLogout) + + override fun onLogoutSuccess(exchange: WebFilterExchange, authentication: Authentication): Mono { + // run, if there is an authentication token + if (authentication is OAuth2AuthenticationToken) { + val oauth = authentication + + // get logout uri from + val postLogoutUri = exchange.exchange.request.headers + .getFirst(logoutProperties.POST_LOGOUT_SUCCESS_URI_HEADER) + ?: exchange.exchange.request.queryParams + .getFirst(logoutProperties.POST_LOGOUT_SUCCESS_URI_PARAM) + ?: defaultPostLogoutUri + + // perform a redirection to the constructed URI + return clientRegistrationRepo + .findByRegistrationId(oauth.authorizedClientRegistrationId) + .flatMap { client -> + val idToken = (oauth.principal as OidcUser).idToken.tokenValue + val uri = if (postLogoutUri.isNullOrBlank()) { + uriBuilder.getLogoutRequestUri(client, idToken) + } else { + uriBuilder.getLogoutRequestUri(client, idToken, URI.create(postLogoutUri)) + } + Mono.justOrEmpty(uri) + } + .flatMap { logoutUri -> + redirectStrategy.sendRedirect(exchange.exchange, URI.create(logoutUri)) + } + } + return Mono.empty() + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/handlers/oauth2/PreAuthorizationCodeOAuth2ServerRedirectStrategyConfig.kt b/Spring BFF/bff/auth/handlers/oauth2/PreAuthorizationCodeOAuth2ServerRedirectStrategyConfig.kt new file mode 100644 index 0000000..67bb341 --- /dev/null +++ b/Spring BFF/bff/auth/handlers/oauth2/PreAuthorizationCodeOAuth2ServerRedirectStrategyConfig.kt @@ -0,0 +1,47 @@ +package com.example.bff.auth.handlers.oauth2 + +import com.example.bff.auth.redirects.OAuth2ServerRedirectStrategy +import com.example.bff.props.OAuth2RedirectionProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.security.web.server.ServerRedirectStrategy + +/**********************************************************************************************************************/ +/**************************************************** REDIRECTION STRATEGIES ******************************************/ +/**********************************************************************************************************************/ + +/** + * Provides a way to set up and manage the PreAuthorizationCodeOAuth2ServerRedirectStrategy bean within the + * Spring application context, enabling it to be used for any pre-authorization re-direction + */ +@Configuration +internal class PreAuthorizationCodeOAuth2ServerRedirectStrategyConfig(){ + + @Bean + // construct the redirect strategy as a function + internal fun preAuthorizationCodeOAuth2RedirectStrategy( + oauth2RedirectionProperties: OAuth2RedirectionProperties, + ): PreAuthorizationCodeServerRedirectStrategy { + return PreAuthorizationCodeOAuth2ServerRedirectStrategy( + oauth2RedirectionProperties.preAuthorizationCode + ) + } + + // pre-defined class + internal class PreAuthorizationCodeOAuth2ServerRedirectStrategy( + defaultStatus: HttpStatus + ) : OAuth2ServerRedirectStrategy( + defaultStatus + ), PreAuthorizationCodeServerRedirectStrategy +} + +/**********************************************************************************************************************/ +/***************************************************** INTERFACES *****************************************************/ +/**********************************************************************************************************************/ + +interface PreAuthorizationCodeServerRedirectStrategy : ServerRedirectStrategy + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/handlers/sessions/ConcurrentSessionControlServerAuthenticationSuccessHandler.kt b/Spring BFF/bff/auth/handlers/sessions/ConcurrentSessionControlServerAuthenticationSuccessHandler.kt new file mode 100644 index 0000000..3c534bb --- /dev/null +++ b/Spring BFF/bff/auth/handlers/sessions/ConcurrentSessionControlServerAuthenticationSuccessHandler.kt @@ -0,0 +1,49 @@ +package com.example.bff.auth.handlers.sessions + +import org.springframework.security.core.Authentication +import org.springframework.security.core.session.ReactiveSessionRegistry +import org.springframework.security.web.server.WebFilterExchange +import org.springframework.security.web.server.authentication.ConcurrentSessionControlServerAuthenticationSuccessHandler +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler +import org.springframework.security.web.server.authentication.ServerMaximumSessionsExceededHandler +import org.springframework.security.web.server.authentication.SessionLimit +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/****************************************************** HANDLER *******************************************************/ +/**********************************************************************************************************************/ + +/** + * Configures a session limit of 1, meaning users can only have one active session at a time. + */ +@Component +internal class CustomConcurrentSessionControlSuccessHandler( + reactiveSessionRegistry: ReactiveSessionRegistry, + maximumSessionsExceededHandler: ServerMaximumSessionsExceededHandler +) : ServerAuthenticationSuccessHandler { + + private val delegate: ConcurrentSessionControlServerAuthenticationSuccessHandler = + ConcurrentSessionControlServerAuthenticationSuccessHandler( + reactiveSessionRegistry, + maximumSessionsExceededHandler + ) + + init { + // configure the default session limit + delegate.setSessionLimit(SessionLimit.of(1)) + } + + // override method + override fun onAuthenticationSuccess( + webFilterExchange: WebFilterExchange, + authentication: Authentication + ): Mono { + return delegate.onAuthenticationSuccess(webFilterExchange, authentication) + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ + diff --git a/Spring BFF/bff/auth/handlers/sessions/ServerMaximumSessionsExceededHandler.kt b/Spring BFF/bff/auth/handlers/sessions/ServerMaximumSessionsExceededHandler.kt new file mode 100644 index 0000000..239d314 --- /dev/null +++ b/Spring BFF/bff/auth/handlers/sessions/ServerMaximumSessionsExceededHandler.kt @@ -0,0 +1,47 @@ +package com.example.bff.auth.handlers.sessions + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.security.web.server.authentication.InvalidateLeastUsedServerMaximumSessionsExceededHandler +import org.springframework.security.web.server.authentication.MaximumSessionsContext +import org.springframework.security.web.server.authentication.PreventLoginServerMaximumSessionsExceededHandler +import org.springframework.security.web.server.authentication.ServerMaximumSessionsExceededHandler +import org.springframework.stereotype.Component +import org.springframework.web.server.adapter.WebHttpHandlerBuilder +import org.springframework.web.server.session.DefaultWebSessionManager +import org.springframework.web.server.session.WebSessionManager +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/****************************************************** HANDLER *******************************************************/ +/**********************************************************************************************************************/ + +//* FOR WHEN MAXIMUM SESSIONS ARE EXCEEDED *// + +/** + * Customizes the behavior when the maximum number of concurrent sessions is exceeded. + * It offers two strategies based on the preventLogin flag. + * If preventLogin is true, it prevents additional logins by using PreventLoginServerMaximumSessionsExceededHandler. + * If preventLogin is false, it invalidates the least used sessions to allow new logins by using + * InvalidateLeastUsedServerMaximumSessionsExceededHandler + */ +@Component +internal class CustomMaximumSessionsExceededHandler( + @Qualifier(WebHttpHandlerBuilder.WEB_SESSION_MANAGER_BEAN_NAME) + private val webSessionManager: WebSessionManager +) : ServerMaximumSessionsExceededHandler { + + private val preventLogin = true // set accordingly + + override fun handle(context: MaximumSessionsContext): Mono { + // choose the implementation based on the flag + return if (preventLogin) { + PreventLoginServerMaximumSessionsExceededHandler().handle(context) + } else { + val sessionStore = (webSessionManager as DefaultWebSessionManager).sessionStore + InvalidateLeastUsedServerMaximumSessionsExceededHandler(sessionStore).handle(context) + } + } +} +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/managers/OAuth2AuthorizedManagerConfig.kt b/Spring BFF/bff/auth/managers/OAuth2AuthorizedManagerConfig.kt new file mode 100644 index 0000000..90a4375 --- /dev/null +++ b/Spring BFF/bff/auth/managers/OAuth2AuthorizedManagerConfig.kt @@ -0,0 +1,59 @@ +package com.example.bff.auth.managers + +import com.example.bff.auth.repositories.authclients.RedisServerOAuth2AuthorizedClientRepository +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager + +/**********************************************************************************************************************/ +/********************************************* AUTHORIZED CLIENT MANAGER **********************************************/ +/**********************************************************************************************************************/ + +/** + * Manages the state of authorized clients, including obtaining, refreshing, and storing access tokens + * Configures and provides a ReactiveOAuth2AuthorizedClientManager bean. This bean manages OAuth2 clients in a + * reactive Spring application, handling client authorization and token management. + * + * ReactiveClientRegistrationRepository: Provides OAuth2 client registration details. + * RedisServerOAuth2AuthorizedClientRepository: Manages authorized OAuth2 clients. + * ReactiveOAuth2AuthorizedClientProvider: Handles token acquisition and refresh. + */ +@Configuration +internal class OAuth2AuthorizedManagerConfig { + + private val logger: Logger = LoggerFactory.getLogger(OAuth2AuthorizedManagerConfig::class.java) + + @Bean + fun reactiveAuthorizedClientManager( + reactiveClientRegistrationRepository: ReactiveClientRegistrationRepository, + redisServerOAuth2AuthorizedClientRepository: RedisServerOAuth2AuthorizedClientRepository, + reactiveAuthorizedClientProvider: ReactiveOAuth2AuthorizedClientProvider, + ): ReactiveOAuth2AuthorizedClientManager { + + logger.info("Creating DefaultReactiveOAuth2AuthorizedClientManager instance") + + // create the AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager instance + val reactiveAuthorizedClientManager = DefaultReactiveOAuth2AuthorizedClientManager( + reactiveClientRegistrationRepository, + redisServerOAuth2AuthorizedClientRepository + ) + + logger.info("Setting ReactiveOAuth2AuthorizedClientProvider") + + // set the authorized client provider to the manager + reactiveAuthorizedClientManager.setAuthorizedClientProvider(reactiveAuthorizedClientProvider) + + logger.info("ReactiveOAuth2AuthorizedClientManager configured successfully") + + return reactiveAuthorizedClientManager + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/mappers/GrantedAuthoritiesMapperConfig.kt b/Spring BFF/bff/auth/mappers/GrantedAuthoritiesMapperConfig.kt new file mode 100644 index 0000000..6b8f702 --- /dev/null +++ b/Spring BFF/bff/auth/mappers/GrantedAuthoritiesMapperConfig.kt @@ -0,0 +1,81 @@ +package com.example.bff.auth.mappers + +import com.example.bff.auth.mappers.converters.ClaimSetAuthoritiesConverter +import com.example.bff.auth.mappers.converters.ConfigurableClaimSetAuthoritiesConverter +import com.example.bff.auth.mappers.converters.OpenIdProviderPropertiesResolver +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority +import org.springframework.security.oauth2.core.user.OAuth2UserAuthority + +/**********************************************************************************************************************/ +/************************************************* AUTHORITY MAPPER ***************************************************/ +/**********************************************************************************************************************/ + +/* + * Params: authoritiesConverter – the authorities converter to use (by default ConfigurableClaimSetAuthoritiesConverter) + * Returns: GrantedAuthoritiesMapper using the authorities converter in the context + * Helps with mapping custom user authorities from ID Token claims or attributes, to Spring SimpleGrantedAuthority + * that are then stored in a security context object + * Note: only gets custom claims from the ID Token (or attributes) - those in Access Token stay in that token! + */ +@Configuration +internal class GrantedAuthoritiesMapperConfig { + + @Bean + fun grantedAuthoritiesMapper( + authoritiesConverter: ClaimSetAuthoritiesConverter, + ): GrantedAuthoritiesMapper { + + return GrantedAuthoritiesMapper { authorities -> + val mappedAuthorities = mutableSetOf() + + authorities.forEach { authority -> + println("PROCESSING AUTHORITY: $authority") + + when (authority) { + is OidcUserAuthority -> { + val idTokenClaims = authority.idToken.claims + println("OidcUserAuthority ID Token Claims: $idTokenClaims") + + val convertedAuthorities = authoritiesConverter.convert(idTokenClaims) ?: emptyList() + println("Converted authorities: $convertedAuthorities") + mappedAuthorities.addAll(convertedAuthorities) + } + is OAuth2UserAuthority -> { + val attributes = authority.attributes + println("OAuth2UserAuthority attributes: $attributes") + + val convertedAuthorities = authoritiesConverter.convert(attributes) ?: emptyList() + println("Converted authorities: $convertedAuthorities") + mappedAuthorities.addAll(convertedAuthorities) + } + } + } + + println("Final mapped authorities: $mappedAuthorities") + mappedAuthorities + } + } + + /* + * Retrieves granted authorities from the Jwt (from its private claims or with the help of an external service) + * and converts them into a collection of GrantedAuthorities + * Note: The term "provider" underscores that this resolver is supplying the properties required for + * further processing, such as mapping claims to authorities in an authentication or authorization context. + */ + @Bean + fun authoritiesConverter( + authoritiesMappingPropertiesProvider: OpenIdProviderPropertiesResolver + ): ClaimSetAuthoritiesConverter { + println("Initializing authorities converter with properties provider: $authoritiesMappingPropertiesProvider") + return ConfigurableClaimSetAuthoritiesConverter(authoritiesMappingPropertiesProvider) + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/mappers/converters/ClaimSetAuthoritiesConverter.kt b/Spring BFF/bff/auth/mappers/converters/ClaimSetAuthoritiesConverter.kt new file mode 100644 index 0000000..333e1c5 --- /dev/null +++ b/Spring BFF/bff/auth/mappers/converters/ClaimSetAuthoritiesConverter.kt @@ -0,0 +1,15 @@ +package com.example.bff.auth.mappers.converters + +import org.springframework.core.convert.converter.Converter +import org.springframework.security.core.GrantedAuthority + +/**********************************************************************************************************************/ +/***************************************************** INTERFACE ******************************************************/ +/**********************************************************************************************************************/ + +internal interface ClaimSetAuthoritiesConverter : Converter, Collection> { +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/mappers/converters/ConfigurableClaimSetAuthoritiesConverter.kt b/Spring BFF/bff/auth/mappers/converters/ConfigurableClaimSetAuthoritiesConverter.kt new file mode 100644 index 0000000..faef1da --- /dev/null +++ b/Spring BFF/bff/auth/mappers/converters/ConfigurableClaimSetAuthoritiesConverter.kt @@ -0,0 +1,97 @@ +package com.example.bff.auth.mappers.converters + +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException +import com.example.bff.props.OidcProviderProperties.SimpleAuthoritiesMappingProperties +import com.example.bff.props.OidcProviderProperties.SimpleAuthoritiesMappingProperties.Case +import com.example.bff.props.OidcProviderProperties.SimpleAuthoritiesMappingProperties.Case.* +import com.jayway.jsonpath.JsonPath +import com.jayway.jsonpath.PathNotFoundException +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Configuration +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.util.StringUtils + +/**********************************************************************************************************************/ +/*********************************************** AUTHORITIES CONVERTER ************************************************/ +/**********************************************************************************************************************/ + +/* + * A converter that takes JWT claims and converts defined ones into Spring Authorities (for the Authentication Object) + */ +@Configuration +internal class ConfigurableClaimSetAuthoritiesConverter ( + private val opPropertiesResolver: OpenIdProviderPropertiesResolver +) : ClaimSetAuthoritiesConverter { + + private val log = LoggerFactory.getLogger(ConfigurableClaimSetAuthoritiesConverter::class.java) + + // find claims and convert them into authorities + override fun convert(source: Map): Collection { + val opProperties = opPropertiesResolver.resolve(source) + .orElseThrow { NotAConfiguredOpenidProviderException(source) } + + log.info("Resolved OpenID provider properties: $opProperties") + + val authorities = opProperties.authorities + .flatMap { authoritiesMappingProps -> getAuthorities(source, authoritiesMappingProps) } + .map { role -> SimpleGrantedAuthority(role) } + + log.info("Converted authorities: $authorities") + return authorities + } + + // get authorities, from relevant claim(s), and return as a list of (formatted) strings + private fun getAuthorities( + claims: Map, + props: SimpleAuthoritiesMappingProperties + ): List { + + val extractedClaims = getClaims(claims, props.path) + + log.debug("Extracted claims for path {}: {}", props.path, extractedClaims) + + return extractedClaims + .flatMap { claim -> claim.split(",").flatMap { it.split(" ") } } + .filter { StringUtils.hasText(it) } + .map { it.trim() } + .map { processCase(it, props.case) } + .map { "${props.prefix}$it" } + } + + // convert to the correct case + private fun processCase(role: String, case: Case): String { + return when (case) { + UPPER -> role.uppercase() + LOWER -> role.lowercase() + else -> role + } + } + + // get claims from claims json (i.e. from the Map) + private fun getClaims(claims: Map, path: String): List { + + log.info("Attempting to extract claims from path: {}", path) + + return try { + when (val res = JsonPath.read(claims, path)) { + is String -> listOf(res) + is List<*> -> when { + res.isEmpty() -> emptyList() + res[0] is String -> res.filterIsInstance() + res[0] is List<*> -> res.flatMap { (it as? List<*>)?.filterIsInstance() ?: emptyList() } + else -> emptyList() + } + else -> emptyList() + } + } catch (e: PathNotFoundException) { + log.info("Path not found: {}. Exception: {}", path, e.message) + emptyList() + } + } + +} + +/**********************************************************************************************************************/ +/*************************************************** END OF KOTLIN ****************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/mappers/converters/OpenIdPropertiesResolversConfig.kt b/Spring BFF/bff/auth/mappers/converters/OpenIdPropertiesResolversConfig.kt new file mode 100644 index 0000000..99f722d --- /dev/null +++ b/Spring BFF/bff/auth/mappers/converters/OpenIdPropertiesResolversConfig.kt @@ -0,0 +1,81 @@ +package com.example.bff.auth.mappers.converters + +import com.example.bff.props.OidcProviderProperties +import com.example.bff.props.OidcProviderProperties.OpenidProviderProperties +import org.slf4j.LoggerFactory + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.stereotype.Component +import java.util.* + +/**********************************************************************************************************************/ +/************************************************ PROPERTIES RESOLVERS ************************************************/ +/**********************************************************************************************************************/ + +/* + * Returns the ByIssuerOpenidProviderPropertiesResolver with a list of oidcProviderProperties passed to it + * Note: The term "resolve" aligns with the functionality of identifying and returning specific properties + * based on some input. + */ +@Configuration +internal class OpenIdPropertiesResolversConfig { + + private val log = LoggerFactory.getLogger(OpenIdPropertiesResolversConfig::class.java) + + @Bean + @Primary + fun openidProviderPropertiesResolver( + oidcProviderProperties: OidcProviderProperties + ): OpenIdProviderPropertiesResolver { + log.info("Building default OpenidProviderPropertiesResolver with the following OpenID provider properties list:") + + oidcProviderProperties.openidProviderPropertiesList.forEach { providerProperties -> + log.info("OpenIdProviderProperties: iss={}, jwkSetUri={}, aud={}, authorities={}, usernameClaim={}", + providerProperties.iss, + providerProperties.jwkSetUri, + providerProperties.aud, + providerProperties.authorities, + providerProperties.usernameClaim + ) + } + return ByIssuerOpenidProviderPropertiesResolver(oidcProviderProperties) + } +} + +/* + * ByIssuerOpenidProviderPropertiesResolver finds the appropriate OpenID provider properties based on the + * issuer claim in a token. + * Note: The term "resolve" aligns with the functionality of identifying and returning specific properties + * based on some input. + */ +@Component +internal class ByIssuerOpenidProviderPropertiesResolver( + private val oidcProviderProperties: OidcProviderProperties +) : OpenIdProviderPropertiesResolver { + + private val log = LoggerFactory.getLogger(ByIssuerOpenidProviderPropertiesResolver::class.java) + + override fun resolve(claimSet: Map): Optional { + val iss = claimSet["iss"]?.toString() + + log.info("Resolving OpenID provider properties for issuer: {}", iss) + + val resolvedProperties = oidcProviderProperties.openidProviderPropertiesList + .find { it.iss?.toString() == iss } + .let { Optional.ofNullable(it) } + + if (resolvedProperties.isPresent) { + log.info("Found matching OpenIdProviderProperties for issuer: {}", iss) + } else { + log.info("No matching OpenIdProviderProperties found for issuer: {}", iss) + } + + return resolvedProperties + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/mappers/converters/OpenIdProviderPropertiesResolver.kt b/Spring BFF/bff/auth/mappers/converters/OpenIdProviderPropertiesResolver.kt new file mode 100644 index 0000000..6d05253 --- /dev/null +++ b/Spring BFF/bff/auth/mappers/converters/OpenIdProviderPropertiesResolver.kt @@ -0,0 +1,19 @@ +package com.example.bff.auth.mappers.converters + +import com.example.bff.props.OidcProviderProperties.OpenidProviderProperties +import java.util.* + +/**********************************************************************************************************************/ +/***************************************************** INTERFACE ******************************************************/ +/**********************************************************************************************************************/ + +/* + * Resolves OpenID Provider configuration properties from OAuth2 / OpenID claims + * (decoded from a JWT, introspected from an opaque token or retrieved from userinfo endpoint) + */ +internal interface OpenIdProviderPropertiesResolver { + fun resolve(claimSet: Map): Optional +} +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/providers/OAuth2AuthorizedClientProviderConfig.kt b/Spring BFF/bff/auth/providers/OAuth2AuthorizedClientProviderConfig.kt new file mode 100644 index 0000000..24cf9ae --- /dev/null +++ b/Spring BFF/bff/auth/providers/OAuth2AuthorizedClientProviderConfig.kt @@ -0,0 +1,47 @@ +package com.example.bff.auth.providers + +import com.example.bff.props.RequestParameterProperties +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository + +/**********************************************************************************************************************/ +/********************************************* AUTHORIZED CLIENT PROVIDER *********************************************/ +/**********************************************************************************************************************/ + +/** + * Provides a mechanism to obtain and refresh OAuth 2.0 access tokens. + * ReactiveOAuth2AuthorizedClientProvider is responsible for managing OAuth2 tokens, which includes obtaining + * access tokens, refreshing them, and handling token expiration. + */ +@Configuration +internal class OAuth2AuthorizedClientProviderConfig { + + private val log = LoggerFactory.getLogger(OAuth2AuthorizedClientProviderConfig::class.java) + + @Bean + @Primary + fun reativeAuthorizedClientProvider( + requestParameterProperties: RequestParameterProperties, + reactiveClientRegistrationRepository: ReactiveClientRegistrationRepository + ): ReactiveOAuth2AuthorizedClientProvider { + + val provider = PerRegistrationReactiveOAuth2AuthorizedClientProvider( + reactiveClientRegistrationRepository, + requestParameterProperties, + emptyMap() + ) + + // Print the details of the provider for debugging + log.info("Created ReactiveOAuth2AuthorizedClientProvider: $provider") + + return provider + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/providers/PerRegistrationReactiveOAuth2AuthorizedClientProvider.kt b/Spring BFF/bff/auth/providers/PerRegistrationReactiveOAuth2AuthorizedClientProvider.kt new file mode 100644 index 0000000..ae15ee7 --- /dev/null +++ b/Spring BFF/bff/auth/providers/PerRegistrationReactiveOAuth2AuthorizedClientProvider.kt @@ -0,0 +1,154 @@ +package com.example.bff.auth.providers + +import com.example.bff.props.RequestParameterProperties +import org.springframework.security.oauth2.client.* +import org.springframework.security.oauth2.client.endpoint.WebClientReactiveClientCredentialsTokenResponseClient +import org.springframework.security.oauth2.client.endpoint.WebClientReactiveRefreshTokenTokenResponseClient +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.core.AuthorizationGrantType +import reactor.core.publisher.Mono +import java.util.concurrent.ConcurrentHashMap + +/**********************************************************************************************************************/ +/********************************************* AUTHORIZED CLIENT PROVIDER **********************************************/ +/**********************************************************************************************************************/ + +/* +* An alternative ReactiveOAuth2AuthorizedClientProvider to DelegatingReactiveOAuth2AuthorizedClientProvider keeping +* a different provider for each client registration. This allows one to define for each, a set of extra parameters +* to add to token requests. +*/ + +/** + * Manages OAuth2 authorization on a per-client registration basis. It dynamically creates and manages providers for + * each client registration ID, handling different OAuth2 grant types (e.g., authorization code, client credentials). + */ + +internal class PerRegistrationReactiveOAuth2AuthorizedClientProvider( + clientRegistrationRepository: ReactiveClientRegistrationRepository, + private val requestParameterProperties: RequestParameterProperties, + private val customProvidersByRegistrationId: Map>, + +) : ReactiveOAuth2AuthorizedClientProvider { + + private val providersByRegistrationId = ConcurrentHashMap() + + + // populate providersByRegistrationId map based on registration respository passed in from class constructor + init { + (clientRegistrationRepository as? InMemoryReactiveClientRegistrationRepository)?.toList()?.forEach { reg -> + val delegate = DelegatingReactiveOAuth2AuthorizedClientProvider( + getProvidersFor(reg, requestParameterProperties) + ) + providersByRegistrationId[reg.registrationId] = delegate + } + } + + + // override authorize function + override fun authorize(context: OAuth2AuthorizationContext?): Mono { + context ?: return Mono.empty() + + // get current client registration from context + val registration = context.clientRegistration + + // if providersByRegistrationId map DOES NOT have provider for given registration id, then assign one + if (!providersByRegistrationId.containsKey(registration.registrationId)) { + val delegate = DelegatingReactiveOAuth2AuthorizedClientProvider( + getProvidersFor(registration, requestParameterProperties) + ) + providersByRegistrationId[registration.registrationId] = delegate + } + + // run the authorization function of the provider + return providersByRegistrationId[registration.registrationId]!!.authorize(context) + } + + + // get provider for the particular client registration + private fun getProvidersFor( + registration: ClientRegistration, + requestParameterProperties: RequestParameterProperties + ): List { + + // get providers for the given client registration id (as passed in from the class constructor) + val providers = ArrayList(customProvidersByRegistrationId[registration.registrationId] ?: listOf()) + + // if grant type is authorisation code, add authorization code provider + // also add refresh token provider (if 'offline_access' scope is provided) + if (AuthorizationGrantType.AUTHORIZATION_CODE == registration.authorizationGrantType) { + providers.add(AuthorizationCodeReactiveOAuth2AuthorizedClientProvider()) + if (registration.scopes.contains("offline_access")) { + providers.add( + createRefreshTokenProvider(registration, requestParameterProperties) + ) + } + // otherwise, if grant type is client credentials, add client credentials provider + } else if (AuthorizationGrantType.CLIENT_CREDENTIALS == registration.authorizationGrantType) { + providers.add( + createClientCredentialsProvider(registration, requestParameterProperties) + ) + } + return providers + } + + + // create a client credentials provider + private fun createClientCredentialsProvider( + registration: ClientRegistration, + requestParameterProperties: RequestParameterProperties + ): ClientCredentialsReactiveOAuth2AuthorizedClientProvider { + + // create provider and get extraParameters + val provider = ClientCredentialsReactiveOAuth2AuthorizedClientProvider() + val extraParameters = requestParameterProperties.getExtraTokenParameters(registration.registrationId) + + // return provider early if no extraParameters + if (extraParameters.isEmpty()) { + return provider + } + + // create response client, and add extra parameters to it + val responseClient = WebClientReactiveClientCredentialsTokenResponseClient() + responseClient.addParametersConverter { extraParameters } + + // add response client into provider + provider.setAccessTokenResponseClient(responseClient) + + // return provider + return provider + } + + + // create a refresh token provider + private fun createRefreshTokenProvider( + registration: ClientRegistration, + requestParameterProperties: RequestParameterProperties + ): RefreshTokenReactiveOAuth2AuthorizedClientProvider { + + // create provider and get extraParameters + val provider = RefreshTokenReactiveOAuth2AuthorizedClientProvider() + val extraParameters = requestParameterProperties.getExtraTokenParameters(registration.registrationId) + + // return provider early if no extraParameters + if (extraParameters.isEmpty()) { + return provider + } + + // create response client, and add extra parameters to it + val responseClient = WebClientReactiveRefreshTokenTokenResponseClient() + responseClient.addParametersConverter { extraParameters } + + // add response client into provider + provider.setAccessTokenResponseClient(responseClient) + + // return provider + return provider + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/redirects/OAuth2ServerRedirectStrategy.kt b/Spring BFF/bff/auth/redirects/OAuth2ServerRedirectStrategy.kt new file mode 100644 index 0000000..3fea3e3 --- /dev/null +++ b/Spring BFF/bff/auth/redirects/OAuth2ServerRedirectStrategy.kt @@ -0,0 +1,63 @@ +package com.example.bff.auth.redirects + +import com.example.bff.props.OAuth2RedirectionProperties +import org.springframework.http.HttpStatus +import org.springframework.http.server.reactive.ServerHttpResponse +import org.springframework.security.web.server.ServerRedirectStrategy +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono +import java.net.URI +import java.util.* + +/**********************************************************************************************************************/ +/**************************************************** REDIRECTION STRATEGIES ******************************************/ +/**********************************************************************************************************************/ + +/** + * A redirect strategy that might not actually redirect: the HTTP status is taken from + * OAuth2RedirectionProperties. User-agents will auto redirect only if the status is in 3xx range. + * If set to 2xx range (like OK, ACCEPTED, NO_CONTENT, ...), this gives single page and mobile applications a chance + * to intercept the redirection and choose to follow the redirection (or not), with which agent, and to potentially + * clear some headers -so a single page or mobile application can handle the redirection as it wishes + * (change the user-agent, clear some headers, ...). + */ + +/** + * Provides a flexible way to handle HTTP redirects by configuring the response status code + * and redirect location dynamically. + */ +internal open class OAuth2ServerRedirectStrategy( + private var defaultStatus: HttpStatus +) : ServerRedirectStrategy { + + private val oauth2ServerRedirectionProperties = OAuth2RedirectionProperties() + + override fun sendRedirect(exchange: ServerWebExchange, location: URI): Mono { + println("RUNNING SERVER REDIRECT STRATEGY: $defaultStatus") + return Mono.fromRunnable { + val response: ServerHttpResponse = exchange.response + // get response status from the following header, otherwise use the passed in default + val status = Optional + .ofNullable( + exchange.request.headers[oauth2ServerRedirectionProperties.RESPONSE_STATUS_HEADER] + ) + .flatMap { it.stream().findAny() } + .filter { it.isNotBlank() } + .map { statusStr -> + try { + HttpStatus.valueOf(statusStr.toInt()) + } catch (e: NumberFormatException) { + HttpStatus.valueOf(statusStr.uppercase()) + } + } + .orElse(defaultStatus) + + response.statusCode = status + response.headers.location = location + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/repositories/authclients/RedisServerOAuth2AuthorizedClientRepository.kt b/Spring BFF/bff/auth/repositories/authclients/RedisServerOAuth2AuthorizedClientRepository.kt new file mode 100644 index 0000000..69112fa --- /dev/null +++ b/Spring BFF/bff/auth/repositories/authclients/RedisServerOAuth2AuthorizedClientRepository.kt @@ -0,0 +1,143 @@ +package com.example.bff.auth.repositories.authclients + +import com.example.bff.auth.serialisers.RedisSerialiserConfig +import com.example.bff.props.SpringSessionProperties +import com.fasterxml.jackson.core.type.TypeReference +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.security.core.Authentication +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository +import org.springframework.stereotype.Repository +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/***************************************************** REPOSITORY *****************************************************/ +/**********************************************************************************************************************/ + +/** + * Enables the use of Redis as a persistent storage solution for Authorized Client objects + */ +@Repository +internal class RedisServerOAuth2AuthorizedClientRepository( + private val redisTemplate: ReactiveRedisTemplate, + springSessionProperties: SpringSessionProperties, + private val redisSerialiserConfig: RedisSerialiserConfig +) : ServerOAuth2AuthorizedClientRepository { + + private val redisKeyPrefix = springSessionProperties.redis?.authorizedClientNameSpace + + override fun loadAuthorizedClient( + clientRegistrationId: String, + principal: Authentication, + exchange: ServerWebExchange + ): Mono { + + return constructRedisKey(exchange).flatMap { redisKey -> + println("LOADING AUTHORIZED CLIENT - REPOSITORY") + println("Redis Key: $redisKey") + + redisTemplate.opsForHash().entries(redisKey) + .doOnNext { entries -> + println("Redis Entries: $entries") + } + .collectMap({ it.key as String }, { it.value }) + .doOnSuccess { map -> + println("Loaded Map from Redis: $map") + } + .mapNotNull { map -> + if (map.isEmpty()) { + println("Loaded map is empty, returning null") + null + } else { + try { + val authorizedClient = redisSerialiserConfig + .redisObjectMapper() + .convertValue(map, OAuth2AuthorizedClient::class.java) as T + println("Deserialized Authorized Client: $authorizedClient") + authorizedClient + } catch (e: Exception) { + println("Error deserializing Authorized Client: ${e.message}") + null + } + } + } + .doOnError { e -> + println("Error loading authorized client: ${e.message}") + } + } + } + + override fun saveAuthorizedClient( + authorizedClient: OAuth2AuthorizedClient, + principal: Authentication, + exchange: ServerWebExchange + ): Mono { + return constructRedisKey(exchange).flatMap { redisKey -> + println("SAVING AUTHORIZED CLIENT - REPOSITORY") + println("Redis Key: $redisKey") + + val hashOperations = redisTemplate.opsForHash() + val fieldsMap = redisSerialiserConfig.redisObjectMapper().convertValue( + authorizedClient, + object : TypeReference>() {} + ) + + println("Authorized Client: $authorizedClient") + + // log the original fields map + println("Original Fields Map: $fieldsMap") + + // remove the clientSecret from the fieldsMap if present + @Suppress("UNCHECKED_CAST") + (fieldsMap["clientRegistration"] as? MutableMap)?.apply { + if (this.containsKey("clientSecret")) { + this["clientSecret"] = "" + println("Client secret set to empty string in clientRegistration map.") + } else { + println("No client secret found in clientRegistration map.") + } + } + + // log the modified fields map + println("Modified Fields Map: $fieldsMap") + + hashOperations.putAll(redisKey, fieldsMap).doOnSuccess { + println("Successfully saved authorized client to Redis") + }.doOnError { e -> + println("Error saving authorized client to Redis: ${e.message}") + }.then() + } + } + + override fun removeAuthorizedClient( + clientRegistrationId: String, + principal: Authentication, + exchange: ServerWebExchange + ): Mono { + return constructRedisKey(exchange).flatMap { redisKey -> + println("REMOVING AUTHORIZED CLIENT - REPOSITORY") + println("Redis Key: $redisKey") + + redisTemplate.opsForHash().delete(redisKey) + .doOnSuccess { + println("Successfully removed authorized client from Redis") + } + .doOnError { e -> + println("Error removing authorized client from Redis: ${e.message}") + } + }.then() + } + + // Helper method to construct the Redis key using a unique identifier from the exchange + private fun constructRedisKey(exchange: ServerWebExchange): Mono { + return exchange.session + .map { it.id } + .map { "$redisKeyPrefix:$it" } + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/repositories/authclients/RedisServerOAuth2AuthorizedClientService.kt b/Spring BFF/bff/auth/repositories/authclients/RedisServerOAuth2AuthorizedClientService.kt new file mode 100644 index 0000000..64e5ab0 --- /dev/null +++ b/Spring BFF/bff/auth/repositories/authclients/RedisServerOAuth2AuthorizedClientService.kt @@ -0,0 +1,141 @@ +package com.example.bff.auth.repositories.authclients + +import com.example.bff.auth.serialisers.RedisSerialiserConfig +import com.example.bff.props.SpringSessionProperties +import com.fasterxml.jackson.core.type.TypeReference +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.security.core.Authentication +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService +import org.springframework.stereotype.Service +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/***************************************************** REPOSITORY *****************************************************/ +/**********************************************************************************************************************/ + +/** + * Enables the use of Redis as a persistent storage solution for Authorized Client objects + */ +@Service +internal class RedisReactiveOAuth2AuthorizedClientService( + private val redisTemplate: ReactiveRedisTemplate, + springSessionProperties: SpringSessionProperties, + private val redisSerialiserConfig: RedisSerialiserConfig +) : ReactiveOAuth2AuthorizedClientService { + + private val redisKeyPrefix = springSessionProperties.redis?.authorizedClientNameSpace + + override fun loadAuthorizedClient( + clientRegistrationId: String, + principalName: String, + ): Mono { + return constructRedisKey(clientRegistrationId, principalName).flatMap { redisKey -> + println("LOADING AUTHORIZED CLIENT - SERVICE") + println("Redis Key: $redisKey") + + redisTemplate.opsForHash().entries(redisKey) + .doOnNext { entries -> + println("Redis Entries: $entries") + } + .collectMap({ it.key as String }, { it.value }) + .doOnSuccess { map -> + println("Loaded Map from Redis: $map") + } + .mapNotNull { map -> + if (map.isEmpty()) { + println("Loaded map is empty, returning null") + null + } else { + try { + val authorizedClient = redisSerialiserConfig + .redisObjectMapper() + .convertValue(map, OAuth2AuthorizedClient::class.java) as T + println("Deserialized Authorized Client: $authorizedClient") + authorizedClient + } catch (e: Exception) { + println("Error deserializing Authorized Client: ${e.message}") + null + } + } + } + .doOnError { e -> + println("Error loading authorized client: ${e.message}") + } + } + } + + override fun saveAuthorizedClient( + authorizedClient: OAuth2AuthorizedClient, + principal: Authentication + ): Mono { + + val clientRegistrationId = authorizedClient.clientRegistration.registrationId + + return constructRedisKey(clientRegistrationId, principal.name).flatMap { redisKey -> + println("SAVING AUTHORIZED CLIENT - SERVICE") + println("Redis Key: $redisKey") + + val fieldsMap = redisSerialiserConfig.redisObjectMapper().convertValue( + authorizedClient, + object : TypeReference>() {} + ) + + println("Authorized Client: $authorizedClient") + + // log the original fields map + println("Original Fields Map: $fieldsMap") + + // remove the clientSecret from the fieldsMap if present + @Suppress("UNCHECKED_CAST") + (fieldsMap["clientRegistration"] as? MutableMap)?.apply { + if (this.containsKey("clientSecret")) { + this["clientSecret"] = "" + println("Client secret set to empty string in clientRegistration map.") + } else { + println("No client secret found in clientRegistration map.") + } + } + + // log the modified fields map + println("Modified Fields Map: $fieldsMap") + + redisTemplate.opsForHash().putAll(redisKey, fieldsMap) + .doOnSuccess { + println("Successfully saved authorized client to Redis") + } + .doOnError { e -> + println("Error saving authorized client to Redis: ${e.message}") + } + .then() + } + } + + override fun removeAuthorizedClient( + clientRegistrationId: String, + principalName: String + ): Mono { + return constructRedisKey(clientRegistrationId, principalName).flatMap { redisKey -> + println("REMOVING AUTHORIZED CLIENT - SERVICE") + println("Redis Key: $redisKey") + + redisTemplate.opsForHash().delete(redisKey) + .doOnSuccess { + println("Successfully removed authorized client from Redis") + } + .doOnError { e -> + println("Error removing authorized client from Redis: ${e.message}") + } + .then() + } + } + + private fun constructRedisKey(clientRegistrationId: String, principalName: String): Mono { + return Mono.just("$redisKeyPrefix:$clientRegistrationId:$principalName") + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/repositories/authrequests/RedisAuthorizationRequestRepository.kt b/Spring BFF/bff/auth/repositories/authrequests/RedisAuthorizationRequestRepository.kt new file mode 100644 index 0000000..f628315 --- /dev/null +++ b/Spring BFF/bff/auth/repositories/authrequests/RedisAuthorizationRequestRepository.kt @@ -0,0 +1,145 @@ +package com.example.bff.auth.repositories + +import com.example.bff.auth.serialisers.RedisSerialiserConfig +import com.example.bff.props.SpringSessionProperties +import com.fasterxml.jackson.core.type.TypeReference +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.security.oauth2.client.web.server.ServerAuthorizationRequestRepository +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest +import org.springframework.stereotype.Repository +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/***************************************************** REPOSITORY *****************************************************/ +/**********************************************************************************************************************/ + +/** + * Enables the use of Redis as a persistent storage solution for OAuth2 authorization requests + */ +@Repository +internal class RedisAuthorizationRequestRepository( + private val redisTemplate: ReactiveRedisTemplate, + springSessionProperties: SpringSessionProperties, + private val redisSerialiserConfig: RedisSerialiserConfig +) : ServerAuthorizationRequestRepository { + + private val redisKeyPrefix = springSessionProperties.redis?.oauth2RequestNameSpace + + override fun saveAuthorizationRequest( + authorizationRequest: OAuth2AuthorizationRequest?, + exchange: ServerWebExchange + ): Mono { + return constructRedisKey(exchange).flatMap { redisKey -> + println("SAVING AUTHORIZATION REQUEST") + println("Redis Key: $redisKey") + + if (authorizationRequest != null) { + + val hashOperations = redisTemplate.opsForHash() + val fieldsMap = redisSerialiserConfig.redisObjectMapper().convertValue( + authorizationRequest, + object : TypeReference>() {} + ) + + println("Authorization Request: $authorizationRequest") + println("Fields Map: $fieldsMap") + + hashOperations.putAll(redisKey, fieldsMap).doOnSuccess { + println("Successfully saved authorization request to Redis") + }.doOnError { e -> + println("Error saving authorization request to Redis: ${e.message}") + }.then() + } else { + println("Authorization request is null, deleting Redis key: $redisKey") + redisTemplate.opsForHash().delete(redisKey) + .doOnSuccess { + println("Successfully deleted authorization request from Redis") + }.doOnError { e -> + println("Error deleting authorization request from Redis: ${e.message}") + }.then() + } + } + } + + override fun loadAuthorizationRequest(exchange: ServerWebExchange): Mono { + return constructRedisKey(exchange).flatMap { redisKey -> + println("LOADING AUTHORIZATION REQUEST") + println("Redis Key: $redisKey") + + redisTemplate.opsForHash().entries(redisKey) + .doOnNext { entries -> + println("Redis Entries: $entries") + } + .collectMap({ it.key as String }, { it.value }) + .doOnSuccess { map -> + println("Loaded Map from Redis: $map") + } + .mapNotNull { map -> + if (map.isEmpty()) { + println("Loaded map is empty, returning null") + null + } else { + try { + val authorizationRequest = redisSerialiserConfig + .redisObjectMapper() + .convertValue(map, OAuth2AuthorizationRequest::class.java) + println("Deserialized OAuth2AuthorizationRequest: $authorizationRequest") + authorizationRequest + } catch (e: Exception) { + println("Error deserializing OAuth2AuthorizationRequest: ${e.message}") + null + } + } + } + .doOnError { e -> + println("Error loading authorization request: ${e.message}") + } + } + } + + override fun removeAuthorizationRequest(exchange: ServerWebExchange): Mono { + println("REMOVING AUTHORIZATION REQUEST") + return constructRedisKey(exchange).flatMap { redisKey -> + println("Attempting to remove Authorization Request with key: $redisKey") + redisTemplate.opsForHash().entries(redisKey) + .doOnNext { entries -> + println("Current Redis Entries: $entries") + } + .collectMap({ it.key as String }, { it.value }) + .doOnNext { map -> + println("Removing Authorization Request with data: $map") + } + .flatMap { map -> + redisTemplate.opsForHash().delete(redisKey) + .then(Mono.fromCallable { + try { + val authorizationRequest = redisSerialiserConfig + .redisObjectMapper() + .convertValue(map, OAuth2AuthorizationRequest::class.java) + println("Successfully removed Authorization Request from Redis. Data: $authorizationRequest") + authorizationRequest + } catch (e: Exception) { + println("Error deserializing Authorization Request after removal: ${e.message}") + null + } + }) + } + .onErrorResume { e -> + println("Error occurred while removing Authorization Request: ${e.message}") + Mono.empty() + } + } + } + + // Helper method to construct the Redis key using a unique identifier from the exchange + private fun constructRedisKey(exchange: ServerWebExchange): Mono { + return exchange.session + .map { it.id } + .map { "$redisKeyPrefix:$it" } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/repositories/clientregistrations/ClientRegistrationRepository.kt b/Spring BFF/bff/auth/repositories/clientregistrations/ClientRegistrationRepository.kt new file mode 100644 index 0000000..4109867 --- /dev/null +++ b/Spring BFF/bff/auth/repositories/clientregistrations/ClientRegistrationRepository.kt @@ -0,0 +1,67 @@ +package com.example.bff.auth.repositories.clientregistrations + +import com.example.bff.props.ClientSecurityProperties +import com.example.bff.props.ServerProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.core.AuthorizationGrantType +import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames + +/**********************************************************************************************************************/ +/***************************************************** REPOSITORY *****************************************************/ +/**********************************************************************************************************************/ + +/** + * Contains configuration details necessary for authenticating with an OAuth2 provider, such as client ID, + * client secret, authorization grant types, scopes, and endpoints. + * THIS STAYS IN MEMORY - IT DOES NOT NEED TO BE PERSISTED TO AN EXTERNAL STORE LIKE REDIS! + */ +@Configuration +internal class ClientRegistrationRepository( + private val serverProperties: ServerProperties, + private val clientSecurityProperties: ClientSecurityProperties, +) { + + @Bean + fun reactiveClientRegistrationRepository(): ReactiveClientRegistrationRepository { + return InMemoryReactiveClientRegistrationRepository( + inHouseAuthRegistration() + ) + } + + // In-House Authentication Provider + private fun inHouseAuthRegistration(): ClientRegistration { + return ClientRegistration + .withRegistrationId(serverProperties.inHouseAuthRegistrationId) + .clientId(clientSecurityProperties.bffClientId) + .clientSecret(clientSecurityProperties.bffClientSecret) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("${serverProperties.clientUri}/login/oauth2/code/${serverProperties.inHouseAuthRegistrationId}") + .authorizationUri("${serverProperties.inHouseIssuerUri}/oauth2/authorize") + .tokenUri("${serverProperties.inHouseIssuerUri}/oauth2/token") + .jwkSetUri("${serverProperties.inHouseIssuerUri}/oauth2/jwks") + .userInfoUri("${serverProperties.inHouseIssuerUri}/userinfo") + .providerConfigurationMetadata(mapOf( + "issuer" to serverProperties.inHouseIssuerUri, + "authorization_endpoint" to "${serverProperties.inHouseIssuerUri}/oauth2/authorize", + "token_endpoint" to "${serverProperties.inHouseIssuerUri}/oauth2/token", + "userinfo_endpoint" to "${serverProperties.inHouseIssuerUri}/userinfo", + "end_session_endpoint" to "${serverProperties.inHouseIssuerUri}/connect/logout", + "jwks_uri" to "${serverProperties.inHouseIssuerUri}/oauth2/jwks", + "revocation_endpoint" to "${serverProperties.inHouseIssuerUri}/oauth2/revoke" + )) + .userNameAttributeName(IdTokenClaimNames.SUB) + .scope("openid") + .clientName("BFF-Server") + .issuerUri(serverProperties.inHouseIssuerUri) + .build() + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/repositories/securitycontext/RedisSecurityContextRepository.kt b/Spring BFF/bff/auth/repositories/securitycontext/RedisSecurityContextRepository.kt new file mode 100644 index 0000000..b143fb6 --- /dev/null +++ b/Spring BFF/bff/auth/repositories/securitycontext/RedisSecurityContextRepository.kt @@ -0,0 +1,103 @@ +package com.example.bff.auth.repositories.securitycontext + +import com.example.bff.auth.serialisers.RedisSerialiserConfig +import com.example.bff.props.SpringSessionProperties +import com.fasterxml.jackson.core.type.TypeReference +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.web.server.context.ServerSecurityContextRepository +import org.springframework.stereotype.Repository +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/***************************************************** REPOSITORY *****************************************************/ +/**********************************************************************************************************************/ + +/** + * Enables the use of Redis as a persistent storage solution for Spring Security Context objects + */ +@Repository +internal class RedisSecurityContextRepository( + private val redisTemplate: ReactiveRedisTemplate, + springSessionProperties: SpringSessionProperties, + private val redisSerialiserConfig: RedisSerialiserConfig +) : ServerSecurityContextRepository { + + private val redisKeyPrefix = springSessionProperties.redis?.securityContextNameSpace + + override fun save( + exchange: ServerWebExchange, + context: SecurityContext + ): Mono { + + return constructRedisKey(exchange).flatMap { redisKey -> + println("SAVING SECURITY CONTEXT") + println("Redis Key: $redisKey") + + val hashOperations = redisTemplate.opsForHash() + val fieldsMap = redisSerialiserConfig.redisObjectMapper().convertValue( + context, + object : TypeReference>() {} + ) + + println("Security Context: $context") + println("Fields Map: $fieldsMap") + + hashOperations.putAll(redisKey, fieldsMap).doOnSuccess { + println("Successfully saved security cotext to Redis") + }.doOnError { e -> + println("Error saving security context to Redis: ${e.message}") + }.then() + } + } + + override fun load( + exchange: ServerWebExchange + ): Mono { + return constructRedisKey(exchange).flatMap { redisKey -> + println("LOADING AUTHORIZATION REQUEST") + println("Redis Key: $redisKey") + + redisTemplate.opsForHash().entries(redisKey) + .doOnNext { entries -> + println("Redis Entries: $entries") + } + .collectMap({ it.key as String }, { it.value }) + .doOnSuccess { map -> + println("Loaded Map from Redis: $map") + } + .mapNotNull { map -> + if (map.isEmpty()) { + println("Loaded map is empty, returning null") + null + } else { + try { + val securityContext = redisSerialiserConfig + .redisObjectMapper() + .convertValue(map, SecurityContext::class.java) + println("Deserialized SecurityContext: $securityContext") + securityContext + } catch (e: Exception) { + println("Error deserializing SecurityContext: ${e.message}") + null + } + } + } + .doOnError { e -> + println("Error loading security context: ${e.message}") + } + } + } + + // Helper method to construct the Redis key using a unique identifier from the exchange + private fun constructRedisKey(exchange: ServerWebExchange): Mono { + return exchange.session + .map { it.id } + .map { "$redisKeyPrefix:$it" } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/repositories/sessions/RedisIndexedSessionRepository.kt b/Spring BFF/bff/auth/repositories/sessions/RedisIndexedSessionRepository.kt new file mode 100644 index 0000000..b071f73 --- /dev/null +++ b/Spring BFF/bff/auth/repositories/sessions/RedisIndexedSessionRepository.kt @@ -0,0 +1,67 @@ +package com.example.bff.auth.repositories.sessions + +import com.example.bff.auth.sessions.CustomSessionIdGenerator +import com.example.bff.props.SpringSessionProperties +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.springframework.data.redis.core.ReactiveRedisOperations +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.session.SaveMode +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository +import org.springframework.stereotype.Repository +import java.time.Duration + +/**********************************************************************************************************************/ +/***************************************************** REPOSITORY *****************************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/redis.html#choosing-between-regular-and-indexed + +@Repository +internal class RedisIndexedSessionRepository ( + private val springSessionProperties: SpringSessionProperties +) { + + @Bean + @Primary + fun reactiveRedisIndexedSessionRepository( + reactiveRedisOperations: ReactiveRedisOperations, + reactiveRedisTemplate: ReactiveRedisTemplate, + eventPublisher: ApplicationEventPublisher + ): ReactiveRedisIndexedSessionRepository { + val repository = ReactiveRedisIndexedSessionRepository(reactiveRedisOperations, reactiveRedisTemplate).apply { + setDefaultMaxInactiveInterval(Duration.ofSeconds(springSessionProperties.timeout.toLong())) + setRedisKeyNamespace(springSessionProperties.redis?.sessionNameSpace) + setSessionIdGenerator(CustomSessionIdGenerator()) + setEventPublisher(eventPublisher) + setSaveMode(SaveMode.ON_SET_ATTRIBUTE) + } + + repository.setIndexResolver { session -> + val indexes = mutableMapOf() + + // safely handle potential null values + val principalName = session.getAttribute("principalName") + val role = session.getAttribute("role") + + // use safe calls or provide default values if necessary + if (principalName != null) { + indexes["PRINCIPAL_NAME_INDEX_NAME"] = principalName + } + if (role != null) { + indexes["ROLE_INDEX_NAME"] = role + } + + indexes + } + + return repository + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/repositories/tokens/CsrfTokenRepository.kt b/Spring BFF/bff/auth/repositories/tokens/CsrfTokenRepository.kt new file mode 100644 index 0000000..bb0540d --- /dev/null +++ b/Spring BFF/bff/auth/repositories/tokens/CsrfTokenRepository.kt @@ -0,0 +1,70 @@ +package com.example.bff.auth.repositories.tokens + +import com.example.bff.props.CsrfProperties +import org.springframework.http.ResponseCookie +import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository +import org.springframework.security.web.server.csrf.CsrfToken +import org.springframework.security.web.server.csrf.ServerCsrfTokenRepository +import org.springframework.stereotype.Repository +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/***************************************************** REPOSITORY *****************************************************/ +/**********************************************************************************************************************/ + +/** + * The CSRF token is validated by comparing the token value from the request (header or parameter) with the token + * value in the cookie. The server does not need to persist or store tokens beyond the duration of the request. + * The actual comparison and validation are handled by CSRF protection filters or components within Spring Security, + * ensuring the token matches without needing additional server-side storage. + */ + +@Repository +internal class CustomServerCsrfTokenRepository( + private val csrfProperties: CsrfProperties +) : ServerCsrfTokenRepository { + + private val cookieServerCsrfTokenRepository = CookieServerCsrfTokenRepository() + + init { + cookieServerCsrfTokenRepository.setCookieName(csrfProperties.CSRF_COOKIE_NAME) + cookieServerCsrfTokenRepository.setCookieCustomizer { cookie -> + cookie.httpOnly(csrfProperties.CSRF_COOKIE_HTTP_ONLY) + cookie.secure(csrfProperties.CSRF_COOKIE_SECURE) + cookie.sameSite(csrfProperties.CSRF_COOKIE_SAME_SITE) + cookie.maxAge(csrfProperties.CSRF_COOKIE_MAX_AGE) + cookie.path(csrfProperties.CSRF_COOKIE_PATH) + } + } + + private fun createCookie(token: CsrfToken?): ResponseCookie { + return ResponseCookie + .from(csrfProperties.CSRF_COOKIE_NAME, token?.token ?: "") + .httpOnly(csrfProperties.CSRF_COOKIE_HTTP_ONLY) + .secure(csrfProperties.CSRF_COOKIE_SECURE) + .sameSite(csrfProperties.CSRF_COOKIE_SAME_SITE) + .maxAge(csrfProperties.CSRF_COOKIE_MAX_AGE) + .path(csrfProperties.CSRF_COOKIE_PATH) + .build() + } + + override fun generateToken(exchange: ServerWebExchange): Mono { + return CookieServerCsrfTokenRepository.withHttpOnlyFalse().generateToken(exchange) + } + + override fun saveToken(exchange: ServerWebExchange, token: CsrfToken?): Mono { + return Mono.fromRunnable { + val cookie = createCookie(token) + exchange.response.addCookie(cookie) + } + } + + override fun loadToken(exchange: ServerWebExchange): Mono { + return CookieServerCsrfTokenRepository.withHttpOnlyFalse().loadToken(exchange) + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/requestcache/ReactiveRequestCache.kt b/Spring BFF/bff/auth/requestcache/ReactiveRequestCache.kt new file mode 100644 index 0000000..455da83 --- /dev/null +++ b/Spring BFF/bff/auth/requestcache/ReactiveRequestCache.kt @@ -0,0 +1,53 @@ +package com.example.bff.auth.requestcache + +import org.springframework.http.server.reactive.ServerHttpRequest +import org.springframework.security.web.server.savedrequest.ServerRequestCache +import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache +import org.springframework.stereotype.Component +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono +import java.net.URI + +/**********************************************************************************************************************/ +/******************************************************* REQUEST CACHE ************************************************/ +/**********************************************************************************************************************/ + +/** + * Saves (caches) the request uri, before any redirect uris, to the user's session, in case needed + */ +@Component +internal class ReactiveRequestCache : ServerRequestCache { + + private val webSessionServerRequestCache = WebSessionServerRequestCache() + + // allows selective use of save request so that only those with 'redirect_uri' parameter are cached + private fun shouldSaveRequest(exchange: ServerWebExchange): Boolean { + val request = exchange.request + return request.queryParams.containsKey("redirect_uri") + } + + override fun saveRequest(exchange: ServerWebExchange): Mono { + if (shouldSaveRequest(exchange)) { + println("Saving request for ${exchange.request.uri}") + return webSessionServerRequestCache.saveRequest(exchange) + } else { + println("Not saving request for ${exchange.request.uri}") + return Mono.empty() + } + } + + override fun getRedirectUri(exchange: ServerWebExchange): Mono { + println("Getting redirect URI for ${exchange.request.uri}") + return webSessionServerRequestCache.getRedirectUri(exchange) + } + + override fun removeMatchingRequest(exchange: ServerWebExchange): Mono { + println("Removing matching request for ${exchange.request.uri}") + return webSessionServerRequestCache.removeMatchingRequest(exchange) + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/resolvers/OAuthAuthorizationRequestResolver.kt b/Spring BFF/bff/auth/resolvers/OAuthAuthorizationRequestResolver.kt new file mode 100644 index 0000000..757c321 --- /dev/null +++ b/Spring BFF/bff/auth/resolvers/OAuthAuthorizationRequestResolver.kt @@ -0,0 +1,300 @@ +package com.example.bff.auth.resolvers + +import com.example.bff.auth.resolvers.customizers.AdditionalParamsAuthorizationRequestCustomizer +import com.example.bff.auth.resolvers.customizers.CompositeOAuth2AuthorizationRequestCustomizer +import com.example.bff.props.LoginProperties +import com.example.bff.props.RequestParameterProperties +import com.example.bff.props.ServerProperties +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.security.oauth2.client.registration.InMemoryReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers +import org.springframework.security.oauth2.client.web.server.DefaultServerOAuth2AuthorizationRequestResolver +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizationRequestResolver +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher +import org.springframework.stereotype.Component +import org.springframework.util.MultiValueMap +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebSession +import org.springframework.web.util.UriComponentsBuilder +import reactor.core.publisher.Mono +import java.net.URI +import java.util.regex.Pattern + +/**********************************************************************************************************************/ +/***************************************************** RESOLVER *******************************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://www.baeldung.com/spring-security-pkce-secret-clients +// https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-oidc + +/** + * Adds the success uri, and failure uri to the user's session (for post-login redirections) + * Potentially modifies the re-direct URI, to handle a proxy server configuration + * Also adds PKCE, and the ability to add extra authorization parameters, if needed + */ +@Component +internal class OAuthAuthorizationRequestResolver( + private val serverProperties: ServerProperties, + private val clientRegistrationRepository: ReactiveClientRegistrationRepository, + private val loginProperties: LoginProperties, + private val requestParameterProperties: RequestParameterProperties +) : ServerOAuth2AuthorizationRequestResolver { + + + // instance variables + private final var authorizationRequestMatcher: ServerWebExchangeMatcher + private final var requestCustomizers: Map = emptyMap() + final val clientRegistrations = (clientRegistrationRepository as? InMemoryReactiveClientRegistrationRepository)?.toList() + ?: emptyList() + + + // initialize instance variables here + init { + this.authorizationRequestMatcher = + PathPatternParserServerWebExchangeMatcher( + DefaultServerOAuth2AuthorizationRequestResolver.DEFAULT_AUTHORIZATION_REQUEST_PATTERN + ) + + this.requestCustomizers = clientRegistrations.associate { clientRegistration -> + val key = clientRegistration.registrationId + val requestCustomizer = CompositeOAuth2AuthorizationRequestCustomizer() + + // add additional authorization request parameters! + val additionalProperties: MultiValueMap = + requestParameterProperties.getExtraAuthorizationParameters(key) + + if (additionalProperties.size > 0) { + requestCustomizer.addCustomizer( + AdditionalParamsAuthorizationRequestCustomizer(additionalProperties) + ) + } + + // add pkce + requestCustomizer.addCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce()) + + // return entry + key to requestCustomizer + } + + } + + + // checks if HTTP request satisfies request matcher; then extracts registration id; runs the other resolver + override fun resolve(exchange: ServerWebExchange?): Mono { + // @formatter:off + return this.authorizationRequestMatcher + .matches(exchange) + .filter { matchResult -> matchResult.isMatch } + .map { matchResult -> matchResult.variables } + .mapNotNull { variables -> variables[DefaultServerOAuth2AuthorizationRequestResolver.DEFAULT_REGISTRATION_ID_URI_VARIABLE_NAME] } + .map { it as String } + .flatMap { clientRegistrationId -> resolve(exchange, clientRegistrationId) } + // @formatter:on + } + + + // runs saveURIs to session function; then gets the request resolver; then runs post processor + override fun resolve( + exchange: ServerWebExchange?, + clientRegistrationId: String? + ): Mono { + if (exchange == null || clientRegistrationId == null) { + return Mono.empty() + } + // obtain the request resolver + val delegate = getRequestResolver(exchange, clientRegistrationId) ?: return Mono.empty() + + // process and return the OAuth2AuthorizationRequest + return savePostLoginUrisInSession(exchange) + // resolve the authorization request + .then(delegate.resolve(exchange, clientRegistrationId)) + // post-process the resolved request + .map { postProcess(it) } + } + + + // saves post login uris (in header or query parameter) to session + private fun savePostLoginUrisInSession(exchange: ServerWebExchange): Mono { + val request = exchange.request + val headers = request.headers + val params = request.queryParams + + return exchange.session.map { session -> + + // Log the session ID and current attributes + println("Session ID: ${session.id}") + println("SESSION ATTRIBUTES BEFORE UPDATE: ${session.attributes}") + session.attributes.entries.forEach { entry -> + println("Attribute: ${entry.key}, Value: ${entry.value}") + } + + // get and process the success URI + val postLoginSuccessUri = headers.getFirst(loginProperties.POST_AUTHENTICATION_SUCCESS_URI_HEADER) + ?: params.getFirst(loginProperties.POST_AUTHENTICATION_SUCCESS_URI_PARAM) + ?.takeIf { it.isNotBlank() } + ?.let { URI.create(it) } + postLoginSuccessUri?.let { + session.attributes[loginProperties.POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE] = it + } + + // get and process the failure URI + val postLoginFailureUri = headers.getFirst(loginProperties.POST_AUTHENTICATION_FAILURE_URI_HEADER) + ?: params.getFirst(loginProperties.POST_AUTHENTICATION_FAILURE_URI_PARAM) + ?.takeIf { it.isNotBlank() } + ?.let { URI.create(it) } + postLoginFailureUri?.let { + session.attributes[loginProperties.POST_AUTHENTICATION_FAILURE_URI_SESSION_ATTRIBUTE] = it + } + + // Log the updated session attributes + println("SESSION ATTTRIBUTES AFTER UPDATE: ${session.attributes}") + session.attributes.entries.forEach { entry -> + println("Attribute: ${entry.key}, Value: ${entry.value}") + } + + // Save session and return + session + } + } + + + // potentially modifies the re-direct URI, to handle a proxy server configuration + private fun postProcess(request: OAuth2AuthorizationRequest): OAuth2AuthorizationRequest { + + // create a mutable copy of the original request + val modified = OAuth2AuthorizationRequest.from(request) + + // parse the original redirect URI + val original = URI.create(request.redirectUri) + + // update redirect URI + val baseUri = URI.create(serverProperties.clientUri) + + // extract the authorities + val originalAuthority = original.authority + val baseAuthority = baseUri.authority + + // extract the first elements of the paths + val originalFirstElement = original.path.split("/").getOrNull(1) + val baseFirstElement = baseUri.path.split("/").getOrNull(1) + + // check if the authorities and the first elements of the paths match + val redirectUri = if (originalAuthority != baseAuthority && originalFirstElement != baseFirstElement) { + + // build the new redirect URI with the original path, query, and fragment + UriComponentsBuilder.fromUri(baseUri) + .path(original.path) + .query(original.query) + .fragment(original.fragment) + .build() + .toUriString() + } else { + original.toString() + } + + // set the modified redirect URI + modified.redirectUri(redirectUri) + + // log the changes + logger.info("Changed OAuth2AuthorizationRequest redirectUri from {} to {}", original, redirectUri) + + // return the modified request + return modified.build() + } + + + /** + * See getOAuth2AuthorizationRequestCustomizer to add advanced request customizer(s) + * + * @param exchange + * @param clientRegistrationId + * @return + */ + protected fun getRequestResolver(exchange: ServerWebExchange, clientRegistrationId: String): ServerOAuth2AuthorizationRequestResolver? { + val requestCustomizer = getOAuth2AuthorizationRequestCustomizer(exchange, clientRegistrationId) + if (requestCustomizer == null) { + return null + } + + val delegate = DefaultServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository) + delegate.setAuthorizationRequestCustomizer(requestCustomizer) + + return delegate + } + + + /** + * Override this to use a "dynamic" request customizer. Something like: + * + *
+     * return CompositeOAuth2AuthorizationRequestCustomizer(
+     *     getCompositeOAuth2AuthorizationRequestCustomizer(clientRegistrationId),
+     *     MyDynamicCustomizer(request),
+     *     ...
+     * )
+     * 
+ * + * @param exchange The ServerWebExchange to customize the request. + * @param clientRegistrationId The client registration ID. + * @return A Consumer for customizing OAuth2AuthorizationRequest. + */ + protected fun getOAuth2AuthorizationRequestCustomizer( + exchange: ServerWebExchange, + clientRegistrationId: String + ): CompositeOAuth2AuthorizationRequestCustomizer? { + return getCompositeOAuth2AuthorizationRequestCustomizer(clientRegistrationId) + } + + + /** + * @param clientRegistrationId The client registration ID. + * @return A request customizer adding PKCE token (if activated) and "static" parameters defined in spring-addons properties. + */ + protected fun getCompositeOAuth2AuthorizationRequestCustomizer( + clientRegistrationId: String + ): CompositeOAuth2AuthorizationRequestCustomizer? { + return this.requestCustomizers[clientRegistrationId] + } + + + // static variables (companion object) + companion object { + + private val logger: Logger = LoggerFactory.getLogger(OAuthAuthorizationRequestResolver::class.java) + private val authorizationRequestPattern: Pattern = Pattern.compile("/oauth2/authorization/([^/]+)") + + /** + * Resolves the registration ID from the exchange's request path. + * + * @param exchange The ServerWebExchange to get the request from. + * @return The resolved registration ID. + */ + @JvmStatic + fun resolveRegistrationId(exchange: ServerWebExchange): String? { + val requestPath = exchange.request.path.toString() + return resolveRegistrationId(requestPath) + } + + /** + * Resolves the registration ID from the request path. + * + * @param requestPath The request path. + * @return The resolved registration ID. + */ + @JvmStatic + fun resolveRegistrationId(requestPath: String): String? { + val matcher = authorizationRequestPattern.matcher(requestPath) + return if (matcher.matches()) matcher.group(1) else null + } + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/resolvers/customizers/AdditionalParamsAuthorizationRequestCustomizer.kt b/Spring BFF/bff/auth/resolvers/customizers/AdditionalParamsAuthorizationRequestCustomizer.kt new file mode 100644 index 0000000..3817635 --- /dev/null +++ b/Spring BFF/bff/auth/resolvers/customizers/AdditionalParamsAuthorizationRequestCustomizer.kt @@ -0,0 +1,29 @@ +package com.example.bff.auth.resolvers.customizers + +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest +import org.springframework.util.MultiValueMap +import java.util.function.Consumer + +/**********************************************************************************************************************/ +/**************************************************** CUSTOMIZER ******************************************************/ +/**********************************************************************************************************************/ + +/** + * adds extra parameters to the OAuth2 authorization request (if needed). + */ +internal class AdditionalParamsAuthorizationRequestCustomizer( + private val additionalParams: MultiValueMap +) : Consumer { + + override fun accept(builder: OAuth2AuthorizationRequest.Builder) { + builder.additionalParameters { params -> + additionalParams.forEach { (key, values) -> + params[key] = values.joinToString(",") + } + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/resolvers/customizers/CompositeOAuth2AuthorizationRequestCustomizer.kt b/Spring BFF/bff/auth/resolvers/customizers/CompositeOAuth2AuthorizationRequestCustomizer.kt new file mode 100644 index 0000000..a4468bc --- /dev/null +++ b/Spring BFF/bff/auth/resolvers/customizers/CompositeOAuth2AuthorizationRequestCustomizer.kt @@ -0,0 +1,44 @@ +package com.example.bff.auth.resolvers.customizers + +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest +import java.util.function.Consumer + +/**********************************************************************************************************************/ +/**************************************************** CUSTOMIZER ******************************************************/ +/**********************************************************************************************************************/ + +/** + * Helps organize and apply multiple customization strategies to the OAuth2 authorization request + * in a modular and flexible way. + */ +internal class CompositeOAuth2AuthorizationRequestCustomizer( + vararg customizers: Consumer +) : Consumer { + + private val delegates: MutableList> = + customizers.toMutableList() + + // secondary constructor to allow extending an existing instance + constructor( + other: CompositeOAuth2AuthorizationRequestCustomizer, + vararg customizers: Consumer + ) : this(*(other.delegates.toTypedArray() + customizers)) + + // applies all customizers to the given OAuth2AuthorizationRequest.Builder + override fun accept(builder: OAuth2AuthorizationRequest.Builder) { + for (consumer in delegates) { + consumer.accept(builder) + } + } + + // adds an additional customizer + fun addCustomizer(customizer: Consumer): + CompositeOAuth2AuthorizationRequestCustomizer { + delegates.add(customizer) + return this + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/serialisers/RedisSerialiserConfig.kt b/Spring BFF/bff/auth/serialisers/RedisSerialiserConfig.kt new file mode 100644 index 0000000..e7073e9 --- /dev/null +++ b/Spring BFF/bff/auth/serialisers/RedisSerialiserConfig.kt @@ -0,0 +1,329 @@ +package com.example.bff.auth.serialisers + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.* +import com.fasterxml.jackson.databind.module.SimpleModule +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import org.springframework.beans.factory.BeanClassLoaderAware +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.RedisSerializer +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextImpl +import org.springframework.security.jackson2.CoreJackson2Module +import org.springframework.security.jackson2.SecurityJackson2Modules +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken +import org.springframework.security.oauth2.client.registration.ClientRegistration +import org.springframework.security.oauth2.core.OAuth2AccessToken +import org.springframework.security.oauth2.core.OAuth2RefreshToken +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType +import org.springframework.security.oauth2.core.oidc.OidcIdToken +import org.springframework.security.oauth2.core.oidc.OidcUserInfo +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority +import java.time.Instant +import java.util.* + +/**********************************************************************************************************************/ +/**************************************************** SERIALISERS *****************************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/reactive-redis-indexed.html + +/** + * User for Serialising to JSON and De-Serialising from JSON, when sending and retrieving from Redis + */ +@Configuration +internal class RedisSerialiserConfig : BeanClassLoaderAware { + + private var loader: ClassLoader? = null + + /** + * Note that the bean name for this bean is intentionally + * {@code springSessionDefaultRedisSerializer}. It must be named this way to override + * the default {@link RedisSerializer} used by Spring Session. + */ + @Bean + // setting a custom session serialiser for Redis + fun springSessionDefaultRedisSerializer(): RedisSerializer { + return object : GenericJackson2JsonRedisSerializer(redisObjectMapper()) { + override fun serialize(value: Any?): ByteArray { + value.let{ + println("Serializing: $value of type: ${value!!::class.java}") + } + println("Serializing: $value") + return super.serialize(value) + } + + override fun deserialize(bytes: ByteArray?): Any { + if (bytes == null || bytes.isEmpty()) { + println("Deserialization: Received null or empty byte array") + return Any() + } + val result = super.deserialize(bytes) + return result + } + } + } + + /** + * Customized {@link ObjectMapper} to add mix-in for class that doesn't have default + * constructors. + * @return the {@link ObjectMapper} to use + */ + fun redisObjectMapper(): ObjectMapper { + val mapper = ObjectMapper() + + // Register custom serializers and deserializers + val module = SimpleModule().apply { + addDeserializer( + OAuth2AuthorizationRequest::class.java, + OAuth2AuthorizationRequestDeserializer() + ) + addDeserializer( + OAuth2AuthorizationResponseType::class.java, + OAuth2AuthorizationResponseTypeDeserializer() + ) + addDeserializer( + SecurityContext::class.java, + SpringSecurityContextDeserializer() + ) + addDeserializer( + OAuth2AuthorizedClient::class.java, + OAuth2AuthorizedClientDeserializer() + ) + } + mapper.registerModule(module) + + // register security-related modules + mapper.registerModule(CoreJackson2Module()) + mapper.registerModules(SecurityJackson2Modules.getModules(this::class.java.classLoader)) + + // deactivate default typing if it is enabled + mapper.deactivateDefaultTyping() + + // additional configurations + mapper.registerModule(JavaTimeModule()) + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL) + return mapper + } + + /** + * Define custom de-serialiser for OAuth2AuthorizationRequest + */ + private class OAuth2AuthorizationRequestDeserializer : JsonDeserializer() { + + override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): OAuth2AuthorizationRequest { + println("Starting deserialization of OAuth2AuthorizationRequest") + + val node = jp.codec.readTree(jp) + + // Extract values from JSON + val authorizationUri = node.get("authorizationUri")?.asText() ?: throw IllegalArgumentException("authorizationUri is required") + val clientId = node.get("clientId")?.asText() ?: throw IllegalArgumentException("clientId is required") + val redirectUri = node.get("redirectUri")?.asText() + val state = node.get("state")?.asText() + val scopes = node.get("scopes")?.elements()?.asSequence()?.map { it.asText() }?.toSet() ?: emptySet() + val additionalParameters = node.get("additionalParameters")?.fields()?.asSequence()?.associate { it.key to it.value.asText() } ?: emptyMap() + val attributes = node.get("attributes")?.fields()?.asSequence()?.associate { it.key to it.value.asText() } ?: emptyMap() + val authorizationRequestUri = node.get("authorizationRequestUri")?.asText() + + // Log extracted values + println( + "Extracted values from JSON: " + + "authorizationUri=$authorizationUri, " + + "clientId=$clientId, " + + "redirectUri=$redirectUri, " + + "state=$state, " + + "scopes=$scopes, " + + "additionalParameters=$additionalParameters, " + + "attributes=$attributes, " + + "authorizationRequestUri=$authorizationRequestUri" + ) + + // Initialize Builder with extracted values + val builder = OAuth2AuthorizationRequest + .authorizationCode() // Assuming the default grant type for the builder + .authorizationUri(authorizationUri) + .clientId(clientId) + .redirectUri(redirectUri) + .scopes(scopes) + .state(state) + .additionalParameters(additionalParameters) + .attributes(attributes) + .authorizationRequestUri(authorizationRequestUri ?: "") + + val authorizationRequest = builder.build() + + // Log final deserialized object + println("Successfully deserialized OAuth2AuthorizationRequest: $authorizationRequest") + + return authorizationRequest + } + + } + + + /** + * Define custom de-serialiser for OAuth2AuthorizationResponseTypeDeserializer + */ + private class OAuth2AuthorizationResponseTypeDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext?): OAuth2AuthorizationResponseType { + val node: JsonNode = p.codec.readTree(p) + + val value = node.get("value")?.asText() ?: throw IllegalArgumentException("Missing value field") + return OAuth2AuthorizationResponseType(value) + } + } + + + /** + * Define custom de-serialiser for Security Context + */ + private class SpringSecurityContextDeserializer : JsonDeserializer() { + + override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): SecurityContext { + val node: JsonNode = jp.codec.readTree(jp) + + // extract the authentication node + val authenticationNode = node.get("authentication") + + // extract principal details + val principalNode = authenticationNode.get("principal") + + // extract authorities + val authorities = principalNode.get("authorities") + .map { authNode -> + val authority = authNode.get("authority").asText() + if (authNode.has("idToken")) { + val idTokenNode = authNode.get("idToken") + val idToken = deserializeOidcIdToken(idTokenNode) + val userInfo = OidcUserInfo(idToken?.claims ?: emptyMap()) + OidcUserAuthority(authority, idToken, userInfo) + } else { + SimpleGrantedAuthority(authority) + } + }.toSet() + + // deserialize the OidcIdToken at the principal level + val idTokenNode = principalNode.get("idToken") + val principalIdToken = deserializeOidcIdToken(idTokenNode) + + // create the OidcUser principal + val nameAttributeKey = principalNode.get("nameAttributeKey").asText() + val principal = DefaultOidcUser(authorities, principalIdToken, nameAttributeKey) + + // extract the authorizedClientRegistrationId + val authorizedClientRegistrationId = authenticationNode.get("authorizedClientRegistrationId").asText() + + // create the Authentication object (assumed OAuth2AuthenticationToken) + val authentication = OAuth2AuthenticationToken(principal, authorities, authorizedClientRegistrationId) + + // create and return the SecurityContextImpl object + return SecurityContextImpl(authentication) + } + + private fun deserializeOidcIdToken(idTokenNode: JsonNode?): OidcIdToken? { + idTokenNode ?: return null + + val tokenValue = idTokenNode.get("tokenValue").asText() + val issuedAt = Instant.parse(idTokenNode.get("issuedAt").asText()) + val expiresAt = Instant.parse(idTokenNode.get("expiresAt").asText()) + + // extract claims + val claimsNode = idTokenNode.get("claims") + val claims = claimsNode.fields().asSequence() + .associate { it.key to it.value.asText() } + + return OidcIdToken(tokenValue, issuedAt, expiresAt, claims) + } + } + + + /** + * Define custom de-serialiser for Authorized Client + */ + private class OAuth2AuthorizedClientDeserializer(): JsonDeserializer() { + + override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): OAuth2AuthorizedClient { + println("Starting deserialization of OAuth2AuthorizedClient") + + val node = jp.codec.readTree(jp) + + // extract 'principalName' + val principalName = node.get("principalName")?.asText() ?: throw IllegalArgumentException("principalName is required") + + // deserialize 'clientRegistration' + val clientRegistrationNode = node.get("clientRegistration") ?: throw IllegalArgumentException("clientRegistration is required") + val clientRegistration = jp.codec.treeToValue(clientRegistrationNode, ClientRegistration::class.java) + ?: throw IllegalArgumentException("Unable to deserialize clientRegistration") + + // deserialize 'accessToken' + val accessTokenNode = node.get("accessToken") ?: throw IllegalArgumentException("accessToken is required") + val tokenTypeValue = accessTokenNode.get("tokenType")?.get("value")?.asText() + ?: throw IllegalArgumentException("tokenType value is required") + val tokenType = when (tokenTypeValue.uppercase(Locale.getDefault())) { + "BEARER" -> OAuth2AccessToken.TokenType.BEARER + else -> throw IllegalArgumentException("Unknown token type: $tokenTypeValue") + } + val accessToken = OAuth2AccessToken( + tokenType, + accessTokenNode.get("tokenValue")?.asText() ?: throw IllegalArgumentException("tokenValue is required"), + accessTokenNode.get("issuedAt")?.asText()?.let { Instant.parse(it) }, + accessTokenNode.get("expiresAt")?.asText()?.let { Instant.parse(it) }, + accessTokenNode.get("scopes")?.elements()?.asSequence()?.map { it.asText() }?.toSet() ?: emptySet() + ) + + // deserialize 'refreshToken' + val refreshTokenNode = node.get("refreshToken") + val refreshToken = refreshTokenNode?.let { + OAuth2RefreshToken( + it.get("tokenValue")?.asText() ?: throw IllegalArgumentException("tokenValue is required"), + it.get("issuedAt")?.asText()?.let { date -> Instant.parse(date) } + ) + } + + // log extracted values + println( + "Extracted values from JSON: " + + "principalName=$principalName, " + + "clientRegistration=$clientRegistration, " + + "accessToken=$accessToken, " + + "refreshToken=$refreshToken" + ) + + // return deserialized OAuth2AuthorizedClient object + return OAuth2AuthorizedClient( + clientRegistration, + principalName, + accessToken, + refreshToken + ).also { + println("Successfully deserialized OAuth2AuthorizedClient: $it") + } + } + + } + + /* + * @see + * org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang.ClassLoader) + */ + override fun setBeanClassLoader(classLoader: ClassLoader) { + this.loader = classLoader + } + + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/sessions/CustomSessionIdGenerator.kt b/Spring BFF/bff/auth/sessions/CustomSessionIdGenerator.kt new file mode 100644 index 0000000..2996895 --- /dev/null +++ b/Spring BFF/bff/auth/sessions/CustomSessionIdGenerator.kt @@ -0,0 +1,35 @@ +package com.example.bff.auth.sessions + +import org.springframework.session.SessionIdGenerator +import java.util.* + +/**********************************************************************************************************************/ +/**************************************************** SESSION ID GENERATOR ********************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/common.html#changing-how-session-ids-are-generated + +/** + * A Custom Session-ID Generator + */ +internal class CustomSessionIdGenerator : SessionIdGenerator { + + val prefix = "BFF" + + override fun generate(): String { + // adds custom prefix, timestamp, and UUID to create a session identifier + return generateEnhancedIdentifier(prefix) + } + + private fun generateEnhancedIdentifier(prefix: String): String { + val timestamp = System.currentTimeMillis() + val uuid = UUID.randomUUID() + return "$prefix-$timestamp-$uuid" + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/sessions/RedisSessionCleanUpConfig.kt b/Spring BFF/bff/auth/sessions/RedisSessionCleanUpConfig.kt new file mode 100644 index 0000000..0e881f1 --- /dev/null +++ b/Spring BFF/bff/auth/sessions/RedisSessionCleanUpConfig.kt @@ -0,0 +1,106 @@ +package com.example.bff.auth.redis + +import com.example.bff.props.SpringSessionProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.domain.Range +import org.springframework.data.redis.connection.Limit +import org.springframework.data.redis.core.ReactiveRedisOperations +import org.springframework.scheduling.annotation.EnableScheduling +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository +import org.springframework.session.data.redis.config.ConfigureReactiveRedisAction +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit + +/**********************************************************************************************************************/ +/************************************************ REDIS CONFIGURATION *************************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/reactive-redis-indexed.html#_configuring_redis_to_send_keyspace_events +// https://docs.spring.io/spring-session/reference/configuration/reactive-redis-indexed.html#how-spring-session-cleans-up-expired-sessions + +@Configuration +@EnableScheduling +internal class RedisCleanUpConfig { + + /** + * No specific configuration or action should be taken regarding Redis keyspace notifications. + */ + @Bean + fun configureReactiveRedisAction(): ConfigureReactiveRedisAction { + return ConfigureReactiveRedisAction.NO_OP + } + + /** + * Disables the default clean up task + */ + @Bean + fun reactiveSessionRepositoryCustomizer(): ReactiveSessionRepositoryCustomizer { + return ReactiveSessionRepositoryCustomizer { sessionRepository: ReactiveRedisIndexedSessionRepository -> + sessionRepository.disableCleanupTask() + } + } +} + +/** + * For cleanup operations (i.e. removing expired session from a ZSet (Sorted Sets) in Redis) + * Spring's scheduling mechanism will automatically call the cleanup method according to the schedule + * defined by the @Scheduled annotation. + */ +@Component +internal class SessionEvicter( + private val redisOperations: ReactiveRedisOperations, + springSessionProperties: SpringSessionProperties, +) { + + private val redisKeyLocation = springSessionProperties.redis?.expiredSessionsNameSpace + ?: "spring:session:sessions:expirations" + + // run every 120 seconds + @Scheduled(fixedRate = 120, timeUnit = TimeUnit.SECONDS) + fun cleanup(): Mono { + val now = Instant.now() + val pastFiveMinutes = now.minus(Duration.ofMinutes(5)) + val range = Range.closed( + (pastFiveMinutes.toEpochMilli()).toDouble(), + (now.toEpochMilli()).toDouble() + ) + val limit = Limit.limit().count(500) + + // get the ZSet (Sorted Set) operations + val zSetOps = redisOperations.opsForZSet() + + return zSetOps.reverseRangeByScore(redisKeyLocation, range, limit) + .collectList() + .flatMap { sessionIdsList -> + if (sessionIdsList.isNotEmpty()) { + println("Found ${sessionIdsList.size} sessions to remove.") + val removal = zSetOps.remove( + redisKeyLocation, + *sessionIdsList.toTypedArray() + ) + removal + } else { + println("No sessions found to remove.") + Mono.empty() + } + } + .doOnSuccess { + println("Cleanup operation completed.") + } + .doOnError { e -> + println("Error during cleanup operation: ${e.message}") + } + .then() + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/sessions/RedisSessionMapperConfig.kt b/Spring BFF/bff/auth/sessions/RedisSessionMapperConfig.kt new file mode 100644 index 0000000..f669e56 --- /dev/null +++ b/Spring BFF/bff/auth/sessions/RedisSessionMapperConfig.kt @@ -0,0 +1,74 @@ +package com.example.bff.auth.sessions + +import com.example.bff.props.SpringSessionProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.core.ReactiveRedisOperations +import org.springframework.session.MapSession +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository +import org.springframework.session.data.redis.RedisSessionMapper +import reactor.core.publisher.Mono +import java.util.function.BiFunction + +/**********************************************************************************************************************/ +/************************************************ REDIS CONFIGURATION *************************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/redis.html#configuring-redis-session-mapper + +@Configuration +internal class RedisSessionMapperConfig() { + + /** + * Customizes the ReactiveRedisIndexedSessionRepository to use the SafeSessionMapper. + */ + @Bean + fun redisSessionRepositoryCustomizer(): ReactiveSessionRepositoryCustomizer { + return ReactiveSessionRepositoryCustomizer { redisSessionRepository -> + redisSessionRepository.setRedisSessionMapper( + SafeRedisSessionMapper( + redisSessionRepository.sessionRedisOperations + ) + ) + } + } + + /** + * Implementation of SafeSessionMapper. + * Enhances the default session mapping behavior by adding error handling, ensuring that any mapping issues + * result in the removal of the problematic session from Redis. + * Improves the robustness and reliability of session management + */ + internal class SafeRedisSessionMapper( + private var redisOperations: ReactiveRedisOperations + ) : BiFunction, Mono>{ + + private val springSessionProperties = SpringSessionProperties() + private val delegate = RedisSessionMapper() + + /** + * Custom session mapper that delegates to the default RedisSessionMapper. + * If an exception occurs, the session is deleted from Redis. + */ + override fun apply(sessionId: String, map: Map): Mono { + return Mono.defer { + try { + // attempt to apply the session mapping + Mono.just(delegate.apply(sessionId, map)) + } catch (ex: IllegalStateException) { + // handle exception: delete session from Redis and return empty Mono + redisOperations.delete("${springSessionProperties.redis?.sessionNameSpace}:$sessionId") + .then(Mono.empty()) + } + } + } + + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/sessions/SessionControl.kt b/Spring BFF/bff/auth/sessions/SessionControl.kt new file mode 100644 index 0000000..e8b1e56 --- /dev/null +++ b/Spring BFF/bff/auth/sessions/SessionControl.kt @@ -0,0 +1,61 @@ +package com.example.bff.auth.sessions + +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository +import org.springframework.session.security.SpringSessionBackedReactiveSessionRegistry +import org.springframework.session.web.server.session.SpringSessionWebSessionStore +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/************************************************** SESSION CONFIGURATION *********************************************/ +/**********************************************************************************************************************/ + +// see more here: +// https://docs.spring.io/spring-security/reference/reactive/authentication/concurrent-sessions-control.html#reactive-concurrent-sessions-control-manually-invalidating-sessions + +/** + * Session control class for invalidation session(s) and then removing them + */ +@Component +internal class SessionControl( + private val reactiveSessionRegistry: CustomSpringSessionReactiveSessionRegistry, + private val reactiveRedisIndexedSessionRepository: ReactiveRedisIndexedSessionRepository, + private val webSessionStore: SpringSessionWebSessionStore +) { + + fun invalidateSessions(username: String): Mono { + return reactiveSessionRegistry.getAllSessions(username) + .flatMap { session -> + session.invalidate() // invalidate each session + .then(webSessionStore.removeSession(session.sessionId)) // remove each from WebSessionStore + .then(Mono.just(session)) // ensure the session object is returned for logging or further processing if needed + } + .then() + .onErrorResume { e -> + // handle errors, e.g., logging + println("Error invalidating sessions: ${e.message}") + Mono.empty() // return empty Mono to signify completion even if an error occurred + } + } + + fun invalidateSession(sessionId: String): Mono { + return reactiveSessionRegistry.getSessionInformation(sessionId) + .flatMap { session -> + // handle the session invalidation process + session.invalidate() // invalidate the session + .then(webSessionStore.removeSession(sessionId)) // remove from WebSessionStore + + } + .then() + .onErrorResume { e -> + // handle errors, e.g., logging + println("Error invalidating session with ID $sessionId: ${e.message}") + Mono.empty() // return empty Mono to signify completion even if an error occurred + } + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/sessions/SessionListenerConfig.kt b/Spring BFF/bff/auth/sessions/SessionListenerConfig.kt new file mode 100644 index 0000000..c9edace --- /dev/null +++ b/Spring BFF/bff/auth/sessions/SessionListenerConfig.kt @@ -0,0 +1,56 @@ +package com.example.bff.auth.sessions + +import org.springframework.context.event.EventListener +import org.springframework.session.events.SessionCreatedEvent +import org.springframework.session.events.SessionDeletedEvent +import org.springframework.session.events.SessionExpiredEvent +import org.springframework.stereotype.Component +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/************************************************** SESSION CONFIGURATION *********************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/reactive-redis-indexed.html#listening-session-events + + +/** + * Listens for session-related events, designed to handle events when a session is created, deleted, or expired. + * Useful for tracking session lifecycles for auditing, security, or resource management purposes. + */ +@Component +// session event listener +internal class SessionListenerConfig { + + @EventListener + fun processSessionCreatedEvent(event: SessionCreatedEvent): Mono { + return Mono.fromRunnable { + // Log or print information about the created session + println("Session created: ${event.sessionId}") + // Your logic for session created event + } + } + + @EventListener + fun processSessionDeletedEvent(event: SessionDeletedEvent): Mono { + return Mono.fromRunnable { + // Log or print information about the deleted session + println("Session deleted: ${event.sessionId}") + // Your logic for session deleted event + } + } + + @EventListener + fun processSessionExpiredEvent(event: SessionExpiredEvent): Mono { + return Mono.fromRunnable { + // Log or print information about the expired session + println("Session expired: ${event.sessionId}") + // Your logic for session expired event + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/sessions/SessionRegistryConfig.kt b/Spring BFF/bff/auth/sessions/SessionRegistryConfig.kt new file mode 100644 index 0000000..a25ec50 --- /dev/null +++ b/Spring BFF/bff/auth/sessions/SessionRegistryConfig.kt @@ -0,0 +1,82 @@ +package com.example.bff.auth.sessions + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.core.session.ReactiveSessionInformation +import org.springframework.security.core.session.ReactiveSessionRegistry +import org.springframework.session.Session +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository +import org.springframework.session.security.SpringSessionBackedReactiveSessionRegistry +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/************************************************** SESSION CONFIGURATION *********************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/common.html#spring-session-backed-reactive-session-registry +// https://docs.spring.io/spring-session/reference/spring-security.html#spring-security-concurrent-sessions + +@Configuration +internal class SessionRegistryConfig { + + /** + * Registers a bean for CustomSpringSessionReactiveSessionRegistry + */ + @Bean + fun sessionRegistry( + reactiveRedisIndexedSessionRepository: ReactiveRedisIndexedSessionRepository, + ): CustomSpringSessionReactiveSessionRegistry { + + val delegate = SpringSessionBackedReactiveSessionRegistry( + reactiveRedisIndexedSessionRepository, + reactiveRedisIndexedSessionRepository + ) + return CustomSpringSessionReactiveSessionRegistry(delegate) + } + +} + + +/** + * Extends the SpringSessionBackedReactiveSessionRegistry registry + */ +internal class CustomSpringSessionReactiveSessionRegistry( + private val delegate: SpringSessionBackedReactiveSessionRegistry +) : ReactiveSessionRegistry { + + override fun getAllSessions(principal: Any?): Flux { + // Custom logic before delegating + println("Custom getAllSessions logic") + return delegate.getAllSessions(principal) + } + + override fun saveSessionInformation(information: ReactiveSessionInformation): Mono { + // Custom logic before delegating + println("Custom saveSessionInformation logic") + return delegate.saveSessionInformation(information) + } + + override fun getSessionInformation(sessionId: String): Mono { + // Custom logic before delegating + println("Custom getSessionInformation logic") + return delegate.getSessionInformation(sessionId) + } + + override fun removeSessionInformation(sessionId: String): Mono { + // Custom logic before delegating + println("Custom removeSessionInformation logic") + return delegate.removeSessionInformation(sessionId) + } + + override fun updateLastAccessTime(sessionId: String): Mono { + // Custom logic before delegating + println("Custom updateLastAccessTime logic") + return delegate.updateLastAccessTime(sessionId) + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/sessions/SessionService.kt b/Spring BFF/bff/auth/sessions/SessionService.kt new file mode 100644 index 0000000..1c0f8dc --- /dev/null +++ b/Spring BFF/bff/auth/sessions/SessionService.kt @@ -0,0 +1,61 @@ +package com.example.bff.auth.sessions + +import org.springframework.session.FindByIndexNameSessionRepository +import org.springframework.session.ReactiveFindByIndexNameSessionRepository +import org.springframework.session.Session +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.security.Principal + +/**********************************************************************************************************************/ +/************************************************ SESSION CONFIGURATION ***********************************************/ +/**********************************************************************************************************************/ + +// more here: +// https://docs.spring.io/spring-session/reference/configuration/redis.html#finding-all-user-sessions + +/** + * Service to retrieve all sessions of a particular user, and remove a session of a particular use + */ +@Service +internal class SessionService( + private val sessions: ReactiveFindByIndexNameSessionRepository, + private val redisIndexedSessionRepository: ReactiveRedisIndexedSessionRepository +) { + + /** + * Retrieves all sessions for a specific user. + * @param principal the principal whose sessions need to be retrieved + * @return a Flux of sessions for the specified user + */ + fun getSessions(principal: Principal): Flux { + return sessions.findByPrincipalName(principal.name) + .flatMapMany { sessionsMap -> + Flux.fromIterable(sessionsMap.values) + } + } + + /** + * Removes a specific session for a user. + * @param principal the principal whose session needs to be removed + * @param sessionIdToDelete the ID of the session to be removed + * @return a Mono indicating completion or error + */ + fun removeSession(principal: Principal, sessionIdToDelete: String): Mono { + return sessions.findByPrincipalName(principal.name) + .flatMap { userSessions -> + if (userSessions.containsKey(sessionIdToDelete)) { + redisIndexedSessionRepository.deleteById(sessionIdToDelete) + } else { + Mono.empty() + } + } + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/sessions/WebSessionStoreConfig.kt b/Spring BFF/bff/auth/sessions/WebSessionStoreConfig.kt new file mode 100644 index 0000000..633a9ef --- /dev/null +++ b/Spring BFF/bff/auth/sessions/WebSessionStoreConfig.kt @@ -0,0 +1,56 @@ +package com.example.bff.auth.sessions + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository +import org.springframework.session.data.redis.ReactiveRedisIndexedSessionRepository.RedisSession +import org.springframework.session.web.server.session.SpringSessionWebSessionStore +import org.springframework.web.server.session.CookieWebSessionIdResolver +import org.springframework.web.server.session.DefaultWebSessionManager +import org.springframework.web.server.session.WebSessionManager + +/**********************************************************************************************************************/ +/************************************************** SESSION CONFIGURATION *********************************************/ +/**********************************************************************************************************************/ + +// see more here: +// https://docs.spring.io/spring-session/reference/web-session.html +// https://docs.spring.io/spring-session/reference/web-session.html#websession-how + +/** + * Configures the session management setup for a Spring WebFlux application, integrating Spring Session with Redis. + */ +@Configuration +internal class WebSessionStoreConfig { + + /** + * Adapts ReactiveRedisIndexedSessionRepository (which stores sessions in Redis) to be usable + * as a WebSessionStore in WebFlux. + */ + @Bean(name = ["webSessionStore"]) + fun webSessionStore( + reactiveRedisIndexedSessionRepository: ReactiveRedisIndexedSessionRepository + ): SpringSessionWebSessionStore { + return SpringSessionWebSessionStore(reactiveRedisIndexedSessionRepository) + } + + /** + * Configures how sessions are managed in WebFlux, using cookies to store session IDs + * and Redis to store session data + */ + @Bean(name = ["webSessionManager"]) + fun webSessionManager( + cookieWebSessionIdResolver: CookieWebSessionIdResolver, + webSessionStore: SpringSessionWebSessionStore + ): WebSessionManager { + val sessionManager = DefaultWebSessionManager() + sessionManager.sessionStore = webSessionStore + sessionManager.sessionIdResolver = cookieWebSessionIdResolver + return sessionManager + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/auth/templates/RedisTemplateConfig.kt b/Spring BFF/bff/auth/templates/RedisTemplateConfig.kt new file mode 100644 index 0000000..2278b0b --- /dev/null +++ b/Spring BFF/bff/auth/templates/RedisTemplateConfig.kt @@ -0,0 +1,71 @@ +package com.example.bff.auth.redis + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory +import org.springframework.data.redis.core.ReactiveRedisOperations +import org.springframework.data.redis.core.ReactiveRedisTemplate +import org.springframework.data.redis.serializer.RedisSerializationContext +import org.springframework.data.redis.serializer.RedisSerializer +import org.springframework.data.redis.serializer.StringRedisSerializer + +/**********************************************************************************************************************/ +/***************************************************** TEMPLATES ******************************************************/ +/**********************************************************************************************************************/ + +/** + * Spring Template and Operations level abstractions for working with Redis, an external key-value store. + */ +@Configuration +internal class RedisTemplateConfig { + + /** + * RedisTemplate: This is the primary abstraction for interacting with Redis in Spring. It provides various methods + * to perform Redis operations, such as setting and getting values, performing transactions, and working with different + * data structures like strings, lists, sets, and hashes. It is a high-level API that simplifies working with Redis. + */ + + @Bean + @Primary + // reactive Redis Template for sessions + fun reactiveSessionRedisTemplate( + connectionFactory: ReactiveRedisConnectionFactory, + springSessionDefaultRedisSerializer: RedisSerializer + ): ReactiveRedisTemplate { + val serializationContext = RedisSerializationContext.newSerializationContext() + .key(StringRedisSerializer()) + .value(springSessionDefaultRedisSerializer) + .hashKey(StringRedisSerializer()) + .hashValue(springSessionDefaultRedisSerializer) + .build() + return ReactiveRedisTemplate(connectionFactory, serializationContext) + } + + /** + * RedisOperations: This is a common interface that RedisTemplate implements. It defines the basic operations + * that can be performed on Redis, such as CRUD (Create, Read, Update, Delete) operations and working with + * different Redis data structures. + */ + @Bean + // reactive Redis Operations for sessions + fun reactiveSessionRedisOperations( + connectionFactory: ReactiveRedisConnectionFactory, + springSessionDefaultRedisSerializer: RedisSerializer + ): ReactiveRedisOperations { + val serializationContext = RedisSerializationContext.newSerializationContext() + .key(StringRedisSerializer()) + .value(springSessionDefaultRedisSerializer) + .hashKey(StringRedisSerializer()) + .hashValue(springSessionDefaultRedisSerializer) + .build() + + return ReactiveRedisTemplate(connectionFactory, serializationContext) + } + + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/props/AppPropertiesConfig.kt b/Spring BFF/bff/props/AppPropertiesConfig.kt new file mode 100644 index 0000000..c2950ed --- /dev/null +++ b/Spring BFF/bff/props/AppPropertiesConfig.kt @@ -0,0 +1,652 @@ +package com.example.bff.props + +import com.c4_soft.springaddons.security.oidc.starter.properties.CorsProperties +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.security.oauth2.core.oidc.StandardClaimNames +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.util.UriComponentsBuilder +import java.net.URI + +/**********************************************************************************************************************/ +/**************************************************** PROPERTIES ******************************************************/ +/**********************************************************************************************************************/ + +/**********************************************************************************************************************/ +/* SERVER PROPERTIES */ +/**********************************************************************************************************************/ +@ConfigurationProperties(prefix = "dse-servers") +@Configuration +internal class ServerProperties { + + /*************************/ + /* SERVERS */ + /*************************/ + // server settings + var scheme: String? = null + var hostname: String? = null + + // reverse proxy settings + var reverseProxyPort: Int? = null + val reverseProxyUri: String + get() = "$scheme://$hostname:$reverseProxyPort" + + // bff server settings + var bffPort: Int? = null + var bffPrefix: String? = null + val bffUri: String + get() = "$scheme://$hostname:$bffPort" + val clientUri: String + get() = "$reverseProxyUri$bffPrefix" + + // resource server settings + var resourceServerPort: Int? = null + var resourceServerPrefix: String? = null + val resourceServerUri: String + get() = "$scheme://$hostname:$resourceServerPort" + + /*************************/ + /* ISSUERS */ + /*************************/ + // in-house authorization server + var authorizationServerPrefix: String? = null + var inHouseAuthRegistrationId: String? = null + val inHouseIssuerUri: String + get() = "$reverseProxyUri$authorizationServerPrefix" + +} + +/**********************************************************************************************************************/ +/* CLIENT PROPERTIES */ +/**********************************************************************************************************************/ +@ConfigurationProperties(prefix = "security") +@Configuration +internal class ClientSecurityProperties { + + // nested class for OAuth2 client registrations + var oauth2: OAuth2Properties = OAuth2Properties() + + // inner classes for structured properties + internal class OAuth2Properties { + var client: ClientProperties = ClientProperties() + + internal class ClientProperties { + var registration: RegistrationProperties = RegistrationProperties() + + // client registrations + internal class RegistrationProperties { + var bff: BffProperties = BffProperties() + var firebase: FirebaseProperties = FirebaseProperties() + + internal class BffProperties { + var clientId: String? = null + var clientSecret: String? = null + } + + internal class FirebaseProperties { + var clientId: String? = null + var clientSecret: String? = null + } + + } + } + } + + // custom getter methods to provide variable names as needed + val bffClientId: String? + get() = oauth2.client.registration.bff.clientId + + val bffClientSecret: String? + get() = oauth2.client.registration.bff.clientSecret + + val firebaseClientId: String? + get() = oauth2.client.registration.firebase.clientId + + val firebaseClientSecret: String? + get() = oauth2.client.registration.firebase.clientSecret + +} + +/**********************************************************************************************************************/ +/* OIDC PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class OidcProviderProperties( + private val serverProperties: ServerProperties, +) { + + /** + * OpenID Providers configuration: JWK set URI, issuer URI, audience, and authorities mapping configuration + * for each issuer. A minimum of one issuer is required. Properties defined here are a replacement for + * spring.security.oauth2.resourceserver.jwt.* (which will be ignored). The reason for that is it is applicable + * only to single tenant scenarios. Use properties. + * Authorities mapping defined there is used by both client and resource server filter-chains. + */ + internal data class OpenidProviderProperties( + + /** + * Must be exactly the same as in access tokens (even trailing slash, if any, is important). + * In case of doubt, open one of your access tokens with a tool like https://jwt.io. + */ + val iss: URI? = null, + + /** + * Can be omitted if OpenID configuration can be retrieved from ${iss}/.well-known/ openid-configuration + */ + val jwkSetUri: URI? = null, + + /** + * Can be omitted. Will insert an audience validator if not null or empty + */ + val aud: String? = null, + + /** + * Authorities mapping configuration, per claim + */ + val authorities: List = listOf(), + + /** + * Authorities mapping configuration, per claim + */ + val usernameClaim: String = StandardClaimNames.SUB + ) + + internal data class SimpleAuthoritiesMappingProperties( + /** + * JSON path of the claim(s) to map with this properties + */ + val path: String = "$.realm_access.roles", + + /** + * What to prefix authorities with (for instance "ROLE_" or "SCOPE_") + */ + val prefix: String = "", + + /** + * Whether to transform authorities to uppercase, lowercase, or to leave it unchanged + */ + val case: Case = Case.UNCHANGED + ) { + enum class Case { + UNCHANGED, + UPPER, + LOWER + } + } + + // id-provider 1 + final val InHouseAuthProvider = OpenidProviderProperties( + iss = URI.create(serverProperties.inHouseIssuerUri), + jwkSetUri = URI.create("${serverProperties.inHouseIssuerUri}/oauth2/jwks"), + aud = "BFF-Server", + authorities = listOf( + SimpleAuthoritiesMappingProperties( + path = "$.authorities", + prefix = "", + case = SimpleAuthoritiesMappingProperties.Case.UPPER + )), + usernameClaim = "sub" + ) + + // final list of OPENID Provider (Issuer) Properties + final val openidProviderPropertiesList: List = listOf( + InHouseAuthProvider + ) +} + +/**********************************************************************************************************************/ +/* SPRING DATA PROPERTIES */ +/**********************************************************************************************************************/ +@ConfigurationProperties(prefix = "spring.data") +@Configuration +internal class SpringDataProperties { + + // Redis properties + var redis: RedisProperties = RedisProperties() + + class RedisProperties { + var host: String = "" + var password: String = "" + var port: Int = 6800 + } +} + +/**********************************************************************************************************************/ +/* SPRING SESSION PROPERTIES */ +/**********************************************************************************************************************/ +@ConfigurationProperties(prefix = "spring.session") +@Configuration +internal class SpringSessionProperties { + + var redis: RedisProperties? = null + var timeout: Int = 21600 // in seconds (6 hours) + + class RedisProperties { + var namespace: String? = null + var repositoryType: String? = null + var flushMode: String? = null + var sessionCleanUpFrequency: Long = 30 // in seconds + val sessionNameSpace: String + get() = namespace.toString() + var oauth2RequestNameSpace: String = "spring:session:bff:oauth2:authorization-request" + var securityContextNameSpace: String = "spring:session:bff:oauth2:security-context" + var authorizedClientNameSpace: String = "spring:session:bff:oauth2:authorized-client" + var expiredSessionsNameSpace: String = "spring:session:bff:oauth2:sessions:expirations" + } +} + +/**********************************************************************************************************************/ +/* CORS PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class CorsProperties( + serverProperties: ServerProperties, + csrfProperties: CsrfProperties +) { + /** + * Path matcher to which this configuration entry applies + */ + final val path: String = "/**" + + final val allowedOriginPatterns: List = listOf( + serverProperties.reverseProxyUri + ) + + final val allowedMethods: List = listOf( + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS" + ) + + final val allowedHeaders: List = listOf( + "Content-Type", + "Authorization", + csrfProperties.CSRF_HEADER_NAME + ) + + final val exposedHeaders: List = listOf( + "Content-Type", + "Authorization", + csrfProperties.CSRF_HEADER_NAME + ) + + final val maxAge: Long = 21600L // in seconds (6 hours) + + /** + * Required if credentials (cookies, authorization headers) are involved + */ + final val allowCredentials: Boolean = true + + /** + * If left to false, OPTIONS requests are added to permit-all for the [path] matchers of this [CorsProperties] + */ + final val disableAnonymousOptions: Boolean = true +} + +/**********************************************************************************************************************/ +/* CSRF PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class CsrfProperties { + + final val CSRF_COOKIE_NAME: String = "XSRF-CLIENT-TOKEN" + final val CSRF_HEADER_NAME: String = "X-XSRF-CLIENT-TOKEN" + + final val CSRF_COOKIE_HTTP_ONLY: Boolean = false // so SPA (e.g. Angular) can read it + final val CSRF_COOKIE_SECURE: Boolean = false // scope is not just on secure connections + final val CSRF_COOKIE_SAME_SITE: String = "Strict" + final val CSRF_COOKIE_MAX_AGE: Long = -1 // session cookie, expires when browser closes + final val CSRF_COOKIE_PATH: String = "/" + +} + +/**********************************************************************************************************************/ +/* SESSION PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class SessionProperties { + + // needs to be called JSESSIONID + // https://docs.spring.io/spring-security/reference/reactive/oauth2/login/logout.html#oauth2login-advanced-oidc-logout + final val SESSION_COOKIE_NAME: String = "CLIENT-SESSIONID" + + final val SESSION_COOKIE_HTTP_ONLY: Boolean = true + final val SESSION_COOKIE_SECURE: Boolean = false // scope is not just on secure connections + final val SESSION_COOKIE_SAME_SITE: String = "Strict" + final val SESSION_COOKIE_MAX_AGE: Long = 21600 // in seconds + final val SESSION_COOKIE_PATH: String = "/" +} + +/**********************************************************************************************************************/ +/* AUTHENTICATION PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class AuthenticationProperties() { + + // securityMatchers for Client security filter chain + final val securityMatchers: List = listOf( + "/api/**", + "/login/**", + "/oauth2/**", + "/logout/**", + "/login-options", + ) +} + + +/**********************************************************************************************************************/ +/* AUTHORIZATION PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class AuthorizationProperties( + serverProperties: ServerProperties +) { + + // allowed permitAlls + final val permitAll: List = listOf( + "/api/**", + "/login/**", + "/oauth2/**", + "/logout/connect/back-channel/${serverProperties.inHouseAuthRegistrationId}", + "/login-options", + ) +} + +/**********************************************************************************************************************/ +/* REQUEST PARAMETER PROPERTIES (AUTHORIZATION & TOKEN ENDPOINTS - NOT LOGOUT) */ +/**********************************************************************************************************************/ +@Component +internal class RequestParameterProperties{ + + /** + * Additional parameters to send with authorization request, mapped by client registration IDs + */ + private val authorizationParams: MutableMap>> = mutableMapOf() + + // get authorizationParameters + final fun getExtraAuthorizationParameters(registrationId: String): MultiValueMap { + return getExtraParameters(registrationId, authorizationParams) + } + + /** + * Additional parameters to send with token request, mapped by client registration IDs + */ + private var tokenParams: MutableMap>> = mutableMapOf() + + // get tokenParameters + final fun getExtraTokenParameters(registrationId: String): MultiValueMap { + return getExtraParameters(registrationId, tokenParams) + } + + /** + * Converts to LinkedMultiValueMap (can store multiple values against each key) + */ + private fun getExtraParameters( + registrationId: String, + requestParamsMap: Map>> + ): MultiValueMap { + val extraParameters = requestParamsMap[registrationId]?.let { otherMap -> + LinkedMultiValueMap(otherMap) + } ?: LinkedMultiValueMap() + + return extraParameters + } + +} + +/**********************************************************************************************************************/ +/* RE-DIRECTION PROPERTIES */ +/**********************************************************************************************************************/ + +/** + * HTTP status for redirections in OAuth2 login and logout. You might set this to something in 2xx range + * (like OK, ACCEPTED, NO_CONTENT, ...) for single page and mobile applications to handle this redirection as it wishes + * (change the user-agent, clear some headers, ...). 2xx WILL NOT automatically re-direct. + */ + +@Component +internal class OAuth2RedirectionProperties() { + + // create instance of server, security properties class + private val serverProperties = ServerProperties() + + /*************************/ + /* LOGIN REDIRECT HOST */ + /*************************/ + /** + * URI containing scheme, host, and port used for redirection + * (defaults to the client URI). + */ + final val postLoginRedirectHostValue: URI? = null + + fun getPostLoginRedirectHost( + ): URI { + return postLoginRedirectHostValue ?: URI.create(serverProperties.clientUri) + } + + /*************************/ + /* SUCCESSFUL LOGIN */ + /*************************/ + /** + * Path used to redirect the user after successful login. + */ + final val postLoginRedirectPath: String? = null + + /** + * Get final constructed redirect URI. + */ + final fun getPostLoginRedirectUri(): URI? { + if (postLoginRedirectHostValue == null && postLoginRedirectPath == null) { + return null + } + + val uriBuilder = UriComponentsBuilder.fromUri(getPostLoginRedirectHost()) + postLoginRedirectPath?.let { uriBuilder.path(it) } + + return uriBuilder.build().toUri() + } + + /*************************/ + /* LOGIN ERROR */ + /*************************/ + /** + * Path used to redirect the user if unsuccessful login. + */ + final val loginErrorRedirectPath: String? = null + + /** + * Get final constructed redirect URI. + */ + final fun getLoginErrorRedirectUri(): URI? { + if (postLoginRedirectHostValue == null && loginErrorRedirectPath == null) { + return null + } + + val uriBuilder = UriComponentsBuilder.fromUri(getPostLoginRedirectHost()) + loginErrorRedirectPath?.let { uriBuilder.path(it) } + + return uriBuilder.build().toUri() + } + + /*************************/ + /* RESPONSE HEADER */ + /*************************/ + /** + * Header used by OAuth2ServerRedirectStrategy to carry the various response codes + */ + final val RESPONSE_STATUS_HEADER: String = "X-RESPONSE-STATUS" + + /*************************/ + /* STATUS CODES */ + /*************************/ + /** + * Status for the 1st response in authorization code flow, with location to get authorization code from authorization server + */ + final val preAuthorizationCode: HttpStatus = HttpStatus.FOUND + + /** + * Status for the response after authorization code, with location to the UI + */ + final val postAuthorizationCode: HttpStatus = HttpStatus.FOUND + + /** + * Status for the response after BFF logout, with location to authorization server logout endpoint + * ACCEPTED IS code 202 - so does not automatically re-direct + */ + final val rpInitiatedLogout: HttpStatus = HttpStatus.ACCEPTED + + + /** + * Map of logout properties indexed by client registration ID + * (must match a registration in Spring Boot OAuth2 client configuration). + * LogoutProperties are configuration for authorization server not strictly following the RP-Initiated Logout + * standard, but exposing a logout end-point expecting an authorized GET request with following request params: + * "client-id" (required) + * post-logout redirect URI (optional) + */ + private val oauth2Logout: Map = mapOf() + + internal fun getLogoutProperties(clientRegistrationId: String?): OAuth2LogoutProperties? { + return oauth2Logout.get(clientRegistrationId) + } + +} + + +/**********************************************************************************************************************/ +/* LOGIN PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class LoginProperties { + + final val LOGIN_URL: String = "/" + + final val POST_AUTHENTICATION_SUCCESS_URI_HEADER: String = "X-POST-LOGIN-SUCCESS-URI" + final val POST_AUTHENTICATION_SUCCESS_URI_PARAM: String = "post_login_success_uri" + final val POST_AUTHENTICATION_SUCCESS_URI_SESSION_ATTRIBUTE = POST_AUTHENTICATION_SUCCESS_URI_PARAM + + final val POST_AUTHENTICATION_FAILURE_URI_HEADER: String = "X-POST-LOGIN-FAILURE-URI" + final val POST_AUTHENTICATION_FAILURE_URI_PARAM: String = "post_login_failure_uri" + final val POST_AUTHENTICATION_FAILURE_URI_SESSION_ATTRIBUTE: String = POST_AUTHENTICATION_FAILURE_URI_PARAM + final val POST_AUTHENTICATION_FAILURE_CAUSE_ATTRIBUTE: String = "error" + +} + +/**********************************************************************************************************************/ +/* LOGOUT PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class LogoutProperties { + + // create instance of server properties class + private val serverProperties = ServerProperties() + + final val LOGOUT_URL: String = "/logout" + + final val POST_LOGOUT_SUCCESS_URI_HEADER: String = "X-POST-LOGOUT-SUCCESS-URI" + final val POST_LOGOUT_SUCCESS_URI_PARAM: String = "post_logout_success_uri" + + /*************************/ + /* LOGOUT REDIRECT HOST */ + /*************************/ + /** + * URI containing scheme, host, and port used for redirection + * (defaults to the client URI). + */ + final val postLogoutRedirectHostValue: URI? = null + + fun getPostLogoutRedirectHost( + ): URI { + return postLogoutRedirectHostValue ?: URI.create(serverProperties.clientUri) + } + + /*************************/ + /* LOGOUT */ + /*************************/ + /** + * Path used to redirect the user on logout + */ + final val postLogoutRedirectPath: String? = null + + /** + * Get final constructed redirect URI. + */ + final fun getPostLogoutRedirectUri(): URI? { + if (postLogoutRedirectHostValue == null && postLogoutRedirectPath == null) { + return null + } + + val uriBuilder = UriComponentsBuilder.fromUri(getPostLogoutRedirectHost()) + postLogoutRedirectPath?.let { uriBuilder.path(it) } + + return uriBuilder.build().toUri() + } + +} + +/**********************************************************************************************************************/ +/* OAUTH2 LOGOUT PROPERTIES */ +/**********************************************************************************************************************/ +internal data class OAuth2LogoutProperties( + /** + * URI on the authorization server where to redirect the user for logout + */ + val uri: URI? = null, + + /** + * Request param name for client-id + */ + val clientIdRequestParam: String? = null, + + /** + * Request param name for post-logout redirect URI + * (where the user should be redirected after their session is closed on the authorization server) + */ + val postLogoutUriRequestParam: String? = null, + + /** + * Request param name for setting an ID-Token hint + */ + val idTokenHintRequestParam: String? = null, + + /** + * RP-Initiated Logout is enabled by default. Setting this to false disables it. + */ + val rpInitiatedLogoutEnabled: Boolean = true +) + +/**********************************************************************************************************************/ +/* BACK CHANNEL LOGOUT PROPERTIES */ +/**********************************************************************************************************************/ +@Component +internal class BackChannelLogoutProperties( + serverProperties: ServerProperties, + logoutProperties: LogoutProperties +) { + + /** + * Enabled by default. + */ + final val enabled = true + + /** + * The URI for a loop of the Spring client to itself in which it actually ends the user session. + * Overriding this can be useful to force the scheme and port in the case where the client is behind a revers proxy + * with different scheme and port (default URI uses the original Back-Channel Logout request scheme and ports). + */ + // internal-logout-uri: ${reverse-proxy-uri}${bff-prefix}/logout should work too, + // but there is no reason to go through the reverse proxy for this internal call + final val internalLogoutUri: String + = "${serverProperties.bffUri}${logoutProperties.LOGOUT_URL}" + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/routing/CustomTokenRelayGatewayFilterFactory.kt b/Spring BFF/bff/routing/CustomTokenRelayGatewayFilterFactory.kt new file mode 100644 index 0000000..d0e15be --- /dev/null +++ b/Spring BFF/bff/routing/CustomTokenRelayGatewayFilterFactory.kt @@ -0,0 +1,228 @@ +package com.example.bff.routing + +import org.springframework.beans.factory.ObjectProvider +import org.springframework.cloud.gateway.filter.GatewayFilter +import org.springframework.cloud.gateway.filter.GatewayFilterChain +import org.springframework.cloud.gateway.filter.factory.TokenRelayGatewayFilterFactory +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.server.reactive.ServerHttpRequest +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken +import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository +import org.springframework.security.oauth2.core.AuthorizationGrantType +import org.springframework.security.oauth2.core.OAuth2AccessToken +import org.springframework.security.oauth2.core.OAuth2RefreshToken +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse +import org.springframework.security.oauth2.core.web.reactive.function.OAuth2BodyExtractors.oauth2AccessTokenResponse +import org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.MultiValueMap +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.server.ServerWebExchange +import reactor.core.publisher.Mono +import java.security.Principal +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.* + + +@Component +internal class CustomTokenRelayGatewayFilterFactory( + clientManagerProvider: ObjectProvider, + private val authorizedClientRepository: ServerOAuth2AuthorizedClientRepository, + private val webClientBuilder: WebClient.Builder +) : TokenRelayGatewayFilterFactory(clientManagerProvider) { + private val accessTokenExpiresSkew: Duration = Duration.ofMinutes(1) + + /** + * Adds access token to header + * Also checks if it has expired. If it has, creates a new one (using the refresh token) + */ + override fun apply(config: NameConfig): GatewayFilter { + return GatewayFilter { exchange: ServerWebExchange, chain: GatewayFilterChain -> + exchange.getPrincipal() + .filter { principal: Principal? -> + if (principal != null) { + println("Principal received: $principal") + } else { + println("No Principal found") + } + principal is OAuth2AuthenticationToken + } + .cast(OAuth2AuthenticationToken::class.java) + .flatMap { authentication: OAuth2AuthenticationToken -> + getAuthorizedClient( + exchange, + authentication + ) + } + .flatMap { authenticationPair -> + if (shouldRefresh(authenticationPair.oAuth2AuthorizedClient)) { + refreshAuthorizedClient( + exchange, + authenticationPair.oAuth2AuthorizedClient, + authenticationPair.oAuth2AuthenticationToken + ) + } else { + Mono.just(authenticationPair.oAuth2AuthorizedClient) + } + } + .map { obj: OAuth2AuthorizedClient -> obj.accessToken } + .map { token: OAuth2AccessToken -> + withBearerAuth( + exchange, + token + ) + } + .defaultIfEmpty(exchange) + .flatMap { modifiedExchange -> + chain.filter(modifiedExchange) + } + } + } + + /** + * Asynchronously loads the OAuth2AuthorizedClient (which holds the tokens and client registration details) + * associated with the current user (represented by the OAuth2AuthenticationToken). It then packages this client + * together with the authentication token into an AuthenticationPair. + */ + private fun getAuthorizedClient( + exchange: ServerWebExchange, + oauth2Authentication: OAuth2AuthenticationToken + ): Mono { + return authorizedClientRepository.loadAuthorizedClient( + oauth2Authentication.authorizedClientRegistrationId, + oauth2Authentication, + exchange + ) + .map { oAuth2AuthorizedClient: OAuth2AuthorizedClient -> + AuthenticationPair( + oAuth2AuthorizedClient, + oauth2Authentication + ) + } + } + + /** + * Checks whether the access token in the OAuth2AuthorizedClient should be refreshed based on its + * expiration time (and skew window), and the presence of a refresh token. + */ + private fun shouldRefresh(authorizedClient: OAuth2AuthorizedClient?): Boolean { + val refreshToken = authorizedClient?.refreshToken + if (refreshToken == null) { + System.err.println("No refresh token available") + return false + } + val now: Instant = CLOCK.instant() + val expiresAt = authorizedClient.accessToken.expiresAt + if (now.isAfter(expiresAt!!.minus(this.accessTokenExpiresSkew))) { + System.err.println("Access token expired and should be refreshed") + return true + } + return false + } + + /** + * Create, with the current refresh token, a new OAuth2AuthorizedClient (with a new access token) + */ + private fun refreshAuthorizedClient( + exchange: ServerWebExchange, + authorizedClient: OAuth2AuthorizedClient, + oauth2Authentication: OAuth2AuthenticationToken + ): Mono { + val headers = HttpHeaders() + val clientRegistration = authorizedClient.clientRegistration + clientRegistration?.clientId?.let { headers.setBasicAuth(it, clientRegistration.clientSecret) } + headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + return webClientBuilder.build() + .method(HttpMethod.POST) + .uri(clientRegistration.providerDetails.tokenUri) + .headers { header: HttpHeaders -> + header.addAll( + headers + ) + } + .bodyValue(refreshTokenBody(authorizedClient.refreshToken!!.tokenValue)) + .exchangeToMono { refreshResponse: ClientResponse -> + if (refreshResponse.statusCode() == HttpStatus.BAD_REQUEST) { + System.err.println("The refresh token or sessions expired.") + throw ResponseStatusException( + HttpStatus.UNAUTHORIZED, + TOKEN_REFRESHMENT_ERROR_MESSAGE + ) + } else { + return@exchangeToMono refreshResponse.body>( + oauth2AccessTokenResponse() + ) + } + } + .map { accessTokenResponse: OAuth2AccessTokenResponse -> + val refreshToken: OAuth2RefreshToken = Optional.ofNullable(accessTokenResponse.refreshToken) + .orElse(authorizedClient.refreshToken) + OAuth2AuthorizedClient( + authorizedClient.clientRegistration, + authorizedClient.principalName, + accessTokenResponse.accessToken, + authorizedClient.refreshToken // (old refresh token) + // refreshToken, // (new refresh token) + ) + } + .flatMap { result: OAuth2AuthorizedClient -> + authorizedClientRepository.saveAuthorizedClient( + result, + oauth2Authentication, + exchange + ).thenReturn(result) + } + } + + /** + * Authentication Pair object + */ + private class AuthenticationPair( + val oAuth2AuthorizedClient: OAuth2AuthorizedClient, + val oAuth2AuthenticationToken: OAuth2AuthenticationToken + ) + + /** + * Static variables and functions + */ + companion object { + private const val TOKEN_REFRESHMENT_ERROR_MESSAGE = "Stale session or token" + private const val GRANT_TYPE_KEY = "grant_type" + private const val REFRESH_TOKEN_KEY = "refresh_token" + private val CLOCK: Clock = Clock.systemUTC() + + /** + * Add Access Token to Header + */ + private fun withBearerAuth(exchange: ServerWebExchange, accessToken: OAuth2AccessToken): ServerWebExchange { + return exchange.mutate() + .request { r: ServerHttpRequest.Builder -> + r.headers { headers: HttpHeaders -> + headers.setBearerAuth( + accessToken.tokenValue + ) + } + } + .build() + } + + /** + * Create body for Refresh Token request + */ + private fun refreshTokenBody(refreshToken: String): MultiValueMap { + val body: MultiValueMap = LinkedMultiValueMap() + body.add(GRANT_TYPE_KEY, AuthorizationGrantType.REFRESH_TOKEN.value) + body.add(REFRESH_TOKEN_KEY, refreshToken) + return body + } + } +} \ No newline at end of file diff --git a/Spring BFF/bff/routing/RoutingConfig.kt b/Spring BFF/bff/routing/RoutingConfig.kt new file mode 100644 index 0000000..a0cb766 --- /dev/null +++ b/Spring BFF/bff/routing/RoutingConfig.kt @@ -0,0 +1,42 @@ +package com.example.bff.routing + +import com.example.bff.props.ServerProperties +import org.springframework.cloud.gateway.route.RouteLocator +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/**********************************************************************************************************************/ +/***************************************************** GATEWAY ROUTING ************************************************/ +/**********************************************************************************************************************/ + +@Configuration +internal class RoutingConfig( + private val serverProperties: ServerProperties, +) { + + @Bean + fun routeLocator(builder: RouteLocatorBuilder): RouteLocator { + return builder.routes() + // routing for Resource Server + .route("resource-server") { r -> + r.path("/api${serverProperties.resourceServerPrefix}/**") + .filters { f -> + f.filter { exchange, chain -> + println("Request Headers: ${exchange.request.headers}") + // add custom filter logic here if needed + chain.filter(exchange) + } + .removeRequestHeader("Cookie") + .dedupeResponseHeader("Access-Control-Allow-Credentials", "RETAIN_UNIQUE") + .dedupeResponseHeader("Access-Control-Allow-Origin", "RETAIN_UNIQUE") + } + .uri(serverProperties.resourceServerUri) + } + .build() + } +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/routing/circuitbreaker/CircuitBreaker.kt b/Spring BFF/bff/routing/circuitbreaker/CircuitBreaker.kt new file mode 100644 index 0000000..13b635c --- /dev/null +++ b/Spring BFF/bff/routing/circuitbreaker/CircuitBreaker.kt @@ -0,0 +1,42 @@ +package com.example.bff.gateway.circuitbreaker + +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig +import io.github.resilience4j.timelimiter.TimeLimiterConfig +import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JCircuitBreakerFactory +import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder +import org.springframework.cloud.client.circuitbreaker.Customizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Duration + +/**********************************************************************************************************************/ +/***************************************************** CIRCUIT BREAKER ************************************************/ +/**********************************************************************************************************************/ + +@Configuration +class Resilience4JConfig { + + @Bean + fun defaultCustomizer(): Customizer { + return Customizer { factory -> + factory.configureDefault { id -> + Resilience4JConfigBuilder(id) + .circuitBreakerConfig(CircuitBreakerConfig.ofDefaults()) + .timeLimiterConfig( + TimeLimiterConfig.custom() + .timeoutDuration( + // 4.5 seconds + Duration.ofSeconds(4, 500_000_000L) + ) + .build() + ) + .build() + } + } + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file diff --git a/Spring BFF/bff/routing/filters/GlobalFilters.kt b/Spring BFF/bff/routing/filters/GlobalFilters.kt new file mode 100644 index 0000000..3d72dfa --- /dev/null +++ b/Spring BFF/bff/routing/filters/GlobalFilters.kt @@ -0,0 +1,90 @@ +package com.example.bff.routing.filters + +import com.example.bff.auth.repositories.authclients.RedisServerOAuth2AuthorizedClientRepository +import com.example.bff.routing.CustomTokenRelayGatewayFilterFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.cloud.gateway.filter.GlobalFilter +import org.springframework.cloud.gateway.filter.factory.SaveSessionGatewayFilterFactory +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.ReactiveSecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + +/**********************************************************************************************************************/ +/******************************************************* FILTER *******************************************************/ +/**********************************************************************************************************************/ + +// The default implementation of ReactiveOAuth2AuthorizedClientService used by TokenRelayGatewayFilterFactory +// uses an in-memory data store. You will need to provide your own implementation of ReactiveOAuth2AuthorizedClientService +// if you need a more robust solution. + +@Configuration +internal class TokenRelayFilterConfig { + + @Bean + fun tokenRelayGatewayFilterFactory( + authorizedClientManagerProvider: ObjectProvider, + authorizedClientRepository: RedisServerOAuth2AuthorizedClientRepository, + webClientBuilder: WebClient.Builder, + ): CustomTokenRelayGatewayFilterFactory { + return CustomTokenRelayGatewayFilterFactory( + authorizedClientManagerProvider, + authorizedClientRepository, + webClientBuilder + ) + } + + @Bean + fun tokenRelayGlobalFilter( + customTokenRelayGatewayFilterFactory: CustomTokenRelayGatewayFilterFactory + ): GlobalFilter { + return GlobalFilter { exchange, chain -> + customTokenRelayGatewayFilterFactory.apply{ + // optionally configure futher if needed + }.filter(exchange, chain) + } + } +} + +@Configuration +internal class SaveSessionFilterConfig( + private val saveSessionGatewayFilterFactory: SaveSessionGatewayFilterFactory +) { + + @Bean + fun saveSessionGlobalFilter(): GlobalFilter { + return GlobalFilter { exchange, chain -> + // Apply the SaveSession filter globally + saveSessionGatewayFilterFactory.apply { + // optionally configure futher if needed + }.filter(exchange, chain) + } + } +} + +@Configuration +internal class CustomHeaderFilterConfig { + + @Bean + fun customHeaderFilter(): GlobalFilter { + return GlobalFilter { exchange, chain -> + chain.filter(exchange).then( + Mono.fromRunnable { + exchange.response.headers.add( + "X-Powered-By", + "DreamStar Enterprises" + ) + } + ) + } + } + +} + +/**********************************************************************************************************************/ +/**************************************************** END OF KOTLIN ***************************************************/ +/**********************************************************************************************************************/ \ No newline at end of file