diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java index 6acd05d2f4d..995e57d5892 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationManagerMethodSecurityConfiguration.java @@ -16,22 +16,17 @@ package org.springframework.security.config.annotation.method.configuration; -import java.util.function.Consumer; -import java.util.function.Supplier; - import io.micrometer.observation.ObservationRegistry; -import org.aopalliance.aop.Advice; import org.aopalliance.intercept.MethodInterceptor; -import org.aopalliance.intercept.MethodInvocation; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.springframework.aop.Pointcut; import org.springframework.aop.framework.AopInfrastructureBean; +import org.springframework.beans.BeansException; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Fallback; @@ -39,18 +34,16 @@ import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.authentication.ReactiveAuthenticationManager; -import org.springframework.security.authorization.ReactiveAuthorizationManager; -import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.security.authorization.ObservationReactiveAuthorizationManager; import org.springframework.security.authorization.method.AuthorizationManagerAfterReactiveMethodInterceptor; import org.springframework.security.authorization.method.AuthorizationManagerBeforeReactiveMethodInterceptor; -import org.springframework.security.authorization.method.MethodInvocationResult; import org.springframework.security.authorization.method.PostAuthorizeReactiveAuthorizationManager; import org.springframework.security.authorization.method.PostFilterAuthorizationReactiveMethodInterceptor; import org.springframework.security.authorization.method.PreAuthorizeReactiveAuthorizationManager; import org.springframework.security.authorization.method.PreFilterAuthorizationReactiveMethodInterceptor; import org.springframework.security.authorization.method.PrePostTemplateDefaults; import org.springframework.security.config.core.GrantedAuthorityDefaults; -import org.springframework.util.function.SingletonSupplier; +import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults; /** * Configuration for a {@link ReactiveAuthenticationManager} based Method Security. @@ -58,59 +51,113 @@ * @author Evgeniy Cheban * @since 5.8 */ -@Configuration(proxyBeanMethods = false) -final class ReactiveAuthorizationManagerMethodSecurityConfiguration implements AopInfrastructureBean { +@Configuration(value = "_reactiveMethodSecurityConfiguration", proxyBeanMethods = false) +final class ReactiveAuthorizationManagerMethodSecurityConfiguration + implements AopInfrastructureBean, ApplicationContextAware { + + private static final Pointcut preFilterPointcut = new PreFilterAuthorizationReactiveMethodInterceptor() + .getPointcut(); + + private static final Pointcut preAuthorizePointcut = AuthorizationManagerBeforeReactiveMethodInterceptor + .preAuthorize() + .getPointcut(); + + private static final Pointcut postAuthorizePointcut = AuthorizationManagerAfterReactiveMethodInterceptor + .postAuthorize() + .getPointcut(); + + private static final Pointcut postFilterPointcut = new PostFilterAuthorizationReactiveMethodInterceptor() + .getPointcut(); + + private PreFilterAuthorizationReactiveMethodInterceptor preFilterMethodInterceptor = new PreFilterAuthorizationReactiveMethodInterceptor(); + + private PreAuthorizeReactiveAuthorizationManager preAuthorizeAuthorizationManager = new PreAuthorizeReactiveAuthorizationManager(); + + private PostAuthorizeReactiveAuthorizationManager postAuthorizeAuthorizationManager = new PostAuthorizeReactiveAuthorizationManager(); + + private PostFilterAuthorizationReactiveMethodInterceptor postFilterMethodInterceptor = new PostFilterAuthorizationReactiveMethodInterceptor(); + + private AuthorizationManagerBeforeReactiveMethodInterceptor preAuthorizeMethodInterceptor; + + private AuthorizationManagerAfterReactiveMethodInterceptor postAuthorizeMethodInterceptor; + + @Autowired(required = false) + ReactiveAuthorizationManagerMethodSecurityConfiguration(MethodSecurityExpressionHandler expressionHandler) { + if (expressionHandler != null) { + this.preFilterMethodInterceptor = new PreFilterAuthorizationReactiveMethodInterceptor(expressionHandler); + this.preAuthorizeAuthorizationManager = new PreAuthorizeReactiveAuthorizationManager(expressionHandler); + this.postFilterMethodInterceptor = new PostFilterAuthorizationReactiveMethodInterceptor(expressionHandler); + this.postAuthorizeAuthorizationManager = new PostAuthorizeReactiveAuthorizationManager(expressionHandler); + } + this.preAuthorizeMethodInterceptor = AuthorizationManagerBeforeReactiveMethodInterceptor + .preAuthorize(this.preAuthorizeAuthorizationManager); + this.postAuthorizeMethodInterceptor = AuthorizationManagerAfterReactiveMethodInterceptor + .postAuthorize(this.postAuthorizeAuthorizationManager); + } + + @Override + public void setApplicationContext(ApplicationContext context) throws BeansException { + this.preAuthorizeAuthorizationManager.setApplicationContext(context); + this.postAuthorizeAuthorizationManager.setApplicationContext(context); + } + + @Autowired(required = false) + void setTemplateDefaults(PrePostTemplateDefaults templateDefaults) { + this.preFilterMethodInterceptor.setTemplateDefaults(templateDefaults); + this.preAuthorizeAuthorizationManager.setTemplateDefaults(templateDefaults); + this.postAuthorizeAuthorizationManager.setTemplateDefaults(templateDefaults); + this.postFilterMethodInterceptor.setTemplateDefaults(templateDefaults); + } + + @Autowired(required = false) + void setTemplateDefaults(AnnotationTemplateExpressionDefaults templateDefaults) { + this.preFilterMethodInterceptor.setTemplateDefaults(templateDefaults); + this.preAuthorizeAuthorizationManager.setTemplateDefaults(templateDefaults); + this.postAuthorizeAuthorizationManager.setTemplateDefaults(templateDefaults); + this.postFilterMethodInterceptor.setTemplateDefaults(templateDefaults); + } + + @Autowired(required = false) + void setObservationRegistry(ObservationRegistry registry) { + if (registry.isNoop()) { + return; + } + this.preAuthorizeMethodInterceptor = AuthorizationManagerBeforeReactiveMethodInterceptor.preAuthorize( + new ObservationReactiveAuthorizationManager<>(registry, this.preAuthorizeAuthorizationManager)); + this.postAuthorizeMethodInterceptor = AuthorizationManagerAfterReactiveMethodInterceptor.postAuthorize( + new ObservationReactiveAuthorizationManager<>(registry, this.postAuthorizeAuthorizationManager)); + } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static MethodInterceptor preFilterAuthorizationMethodInterceptor(MethodSecurityExpressionHandler expressionHandler, - ObjectProvider defaultsObjectProvider) { - PreFilterAuthorizationReactiveMethodInterceptor interceptor = new PreFilterAuthorizationReactiveMethodInterceptor( - expressionHandler); - return new DeferringMethodInterceptor<>(interceptor, - (i) -> defaultsObjectProvider.ifAvailable(i::setTemplateDefaults)); + static MethodInterceptor preFilterAuthorizationMethodInterceptor( + ObjectProvider _reactiveMethodSecurityConfiguration) { + return new DeferringMethodInterceptor<>(preFilterPointcut, + () -> _reactiveMethodSecurityConfiguration.getObject().preFilterMethodInterceptor); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) static MethodInterceptor preAuthorizeAuthorizationMethodInterceptor( - MethodSecurityExpressionHandler expressionHandler, - ObjectProvider defaultsObjectProvider, - ObjectProvider registryProvider, ApplicationContext context) { - PreAuthorizeReactiveAuthorizationManager manager = new PreAuthorizeReactiveAuthorizationManager( - expressionHandler); - manager.setApplicationContext(context); - ReactiveAuthorizationManager authorizationManager = manager(manager, registryProvider); - AuthorizationAdvisor interceptor = AuthorizationManagerBeforeReactiveMethodInterceptor - .preAuthorize(authorizationManager); - return new DeferringMethodInterceptor<>(interceptor, - (i) -> defaultsObjectProvider.ifAvailable(manager::setTemplateDefaults)); + ObjectProvider _reactiveMethodSecurityConfiguration) { + return new DeferringMethodInterceptor<>(preAuthorizePointcut, + () -> _reactiveMethodSecurityConfiguration.getObject().preAuthorizeMethodInterceptor); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static MethodInterceptor postFilterAuthorizationMethodInterceptor(MethodSecurityExpressionHandler expressionHandler, - ObjectProvider defaultsObjectProvider) { - PostFilterAuthorizationReactiveMethodInterceptor interceptor = new PostFilterAuthorizationReactiveMethodInterceptor( - expressionHandler); - return new DeferringMethodInterceptor<>(interceptor, - (i) -> defaultsObjectProvider.ifAvailable(i::setTemplateDefaults)); + static MethodInterceptor postFilterAuthorizationMethodInterceptor( + ObjectProvider _reactiveMethodSecurityConfiguration) { + return new DeferringMethodInterceptor<>(postFilterPointcut, + () -> _reactiveMethodSecurityConfiguration.getObject().postFilterMethodInterceptor); } @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) static MethodInterceptor postAuthorizeAuthorizationMethodInterceptor( - MethodSecurityExpressionHandler expressionHandler, - ObjectProvider defaultsObjectProvider, - ObjectProvider registryProvider, ApplicationContext context) { - PostAuthorizeReactiveAuthorizationManager manager = new PostAuthorizeReactiveAuthorizationManager( - expressionHandler); - manager.setApplicationContext(context); - ReactiveAuthorizationManager authorizationManager = manager(manager, registryProvider); - AuthorizationAdvisor interceptor = AuthorizationManagerAfterReactiveMethodInterceptor - .postAuthorize(authorizationManager); - return new DeferringMethodInterceptor<>(interceptor, - (i) -> defaultsObjectProvider.ifAvailable(manager::setTemplateDefaults)); + ObjectProvider _reactiveMethodSecurityConfiguration) { + return new DeferringMethodInterceptor<>(postAuthorizePointcut, + () -> _reactiveMethodSecurityConfiguration.getObject().postAuthorizeMethodInterceptor); } @Bean @@ -125,55 +172,4 @@ static DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler( return handler; } - static ReactiveAuthorizationManager manager(ReactiveAuthorizationManager delegate, - ObjectProvider registryProvider) { - return new DeferringObservationReactiveAuthorizationManager<>(registryProvider, delegate); - } - - private static final class DeferringMethodInterceptor - implements AuthorizationAdvisor { - - private final Pointcut pointcut; - - private final int order; - - private final Supplier delegate; - - DeferringMethodInterceptor(M delegate, Consumer supplier) { - this.pointcut = delegate.getPointcut(); - this.order = delegate.getOrder(); - this.delegate = SingletonSupplier.of(() -> { - supplier.accept(delegate); - return delegate; - }); - } - - @Nullable - @Override - public Object invoke(@NotNull MethodInvocation invocation) throws Throwable { - return this.delegate.get().invoke(invocation); - } - - @Override - public Pointcut getPointcut() { - return this.pointcut; - } - - @Override - public Advice getAdvice() { - return this; - } - - @Override - public int getOrder() { - return this.order; - } - - @Override - public boolean isPerInstance() { - return true; - } - - } - } diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationProxyConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationProxyConfiguration.java deleted file mode 100644 index 7912991c4fb..00000000000 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveAuthorizationProxyConfiguration.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2002-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.security.config.annotation.method.configuration; - -import java.util.ArrayList; -import java.util.List; - -import org.aopalliance.intercept.MethodInterceptor; - -import org.springframework.aop.framework.AopInfrastructureBean; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.config.BeanDefinition; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Role; -import org.springframework.security.authorization.method.AuthorizationAdvisor; -import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory; -import org.springframework.security.authorization.method.AuthorizeReturnObjectMethodInterceptor; -import org.springframework.security.config.Customizer; - -@Configuration(proxyBeanMethods = false) -final class ReactiveAuthorizationProxyConfiguration implements AopInfrastructureBean { - - @Bean - @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static AuthorizationAdvisorProxyFactory authorizationProxyFactory(ObjectProvider provider, - ObjectProvider> customizers) { - List advisors = new ArrayList<>(); - provider.forEach(advisors::add); - AuthorizationAdvisorProxyFactory factory = AuthorizationAdvisorProxyFactory.withReactiveDefaults(); - customizers.forEach((c) -> c.customize(factory)); - factory.setAdvisors(advisors); - return factory; - } - - @Bean - @Role(BeanDefinition.ROLE_INFRASTRUCTURE) - static MethodInterceptor authorizeReturnObjectMethodInterceptor(ObjectProvider provider, - AuthorizationAdvisorProxyFactory authorizationProxyFactory) { - AuthorizeReturnObjectMethodInterceptor interceptor = new AuthorizeReturnObjectMethodInterceptor( - authorizationProxyFactory); - List advisors = new ArrayList<>(); - provider.forEach(advisors::add); - advisors.add(interceptor); - authorizationProxyFactory.setAdvisors(advisors); - return interceptor; - } - -} diff --git a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java index b1c923383e5..dbedbeab605 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java +++ b/config/src/main/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecuritySelector.java @@ -26,6 +26,7 @@ import org.springframework.context.annotation.ImportSelector; import org.springframework.core.type.AnnotationMetadata; import org.springframework.lang.NonNull; +import org.springframework.util.ClassUtils; /** * @author Rob Winch @@ -34,6 +35,9 @@ */ class ReactiveMethodSecuritySelector implements ImportSelector { + private static final boolean isDataPresent = ClassUtils + .isPresent("org.springframework.security.data.aot.hint.AuthorizeReturnObjectDataHintsRegistrar", null); + private final ImportSelector autoProxy = new AutoProxyRegistrarSelector(); @Override @@ -51,7 +55,10 @@ public String[] selectImports(AnnotationMetadata importMetadata) { else { imports.add(ReactiveMethodSecurityConfiguration.class.getName()); } - imports.add(ReactiveAuthorizationProxyConfiguration.class.getName()); + if (isDataPresent) { + imports.add(AuthorizationProxyDataConfiguration.class.getName()); + } + imports.add(AuthorizationProxyConfiguration.class.getName()); return imports.toArray(new String[0]); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java index 184042f8231..485c068e85b 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2ClientConfigurerUtils.java @@ -116,10 +116,17 @@ private static > OAuth2AuthorizedClientService static > OidcSessionRegistry getOidcSessionRegistry(B builder) { OidcSessionRegistry sessionRegistry = builder.getSharedObject(OidcSessionRegistry.class); - if (sessionRegistry == null) { + if (sessionRegistry != null) { + return sessionRegistry; + } + ApplicationContext context = builder.getSharedObject(ApplicationContext.class); + if (context.getBeanNamesForType(OidcSessionRegistry.class).length == 1) { + sessionRegistry = context.getBean(OidcSessionRegistry.class); + } + else { sessionRegistry = new InMemoryOidcSessionRegistry(); - builder.setSharedObject(OidcSessionRegistry.class, sessionRegistry); } + builder.setSharedObject(OidcSessionRegistry.class, sessionRegistry); return sessionRegistry; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java index f65b1c11c09..73f76bffd78 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthentication.java @@ -20,6 +20,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; /** * An {@link org.springframework.security.core.Authentication} implementation that @@ -37,13 +38,16 @@ class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { private final OidcLogoutToken logoutToken; + private final ClientRegistration clientRegistration; + /** * Construct an {@link OidcBackChannelLogoutAuthentication} * @param logoutToken a deserialized, verified OIDC Logout Token */ - OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) { + OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken, ClientRegistration clientRegistration) { super(Collections.emptyList()); this.logoutToken = logoutToken; + this.clientRegistration = clientRegistration; setAuthenticated(true); } @@ -63,4 +67,8 @@ public OidcLogoutToken getCredentials() { return this.logoutToken; } + ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java index d25153ce7b2..f1412fd660a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutAuthenticationProvider.java @@ -99,7 +99,7 @@ public Authentication authenticate(Authentication authentication) throws Authent OidcLogoutToken oidcLogoutToken = OidcLogoutToken.withTokenValue(logoutToken) .claims((claims) -> claims.putAll(jwt.getClaims())) .build(); - return new OidcBackChannelLogoutAuthentication(oidcLogoutToken); + return new OidcBackChannelLogoutAuthentication(oidcLogoutToken, registration); } /** diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java index 0a03ec83838..f76aeb8c30e 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutFilter.java @@ -58,7 +58,7 @@ class OidcBackChannelLogoutFilter extends OncePerRequestFilter { private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); - private LogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + private final LogoutHandler logoutHandler; /** * Construct an {@link OidcBackChannelLogoutFilter} @@ -68,11 +68,13 @@ class OidcBackChannelLogoutFilter extends OncePerRequestFilter { * Logout Tokens */ OidcBackChannelLogoutFilter(AuthenticationConverter authenticationConverter, - AuthenticationManager authenticationManager) { + AuthenticationManager authenticationManager, LogoutHandler logoutHandler) { Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); this.authenticationConverter = authenticationConverter; this.authenticationManager = authenticationManager; + this.logoutHandler = logoutHandler; } /** @@ -126,14 +128,4 @@ private OAuth2Error oauth2Error(Exception ex) { "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"); } - /** - * The strategy for expiring all Client sessions indicated by the logout request. - * Defaults to {@link OidcBackChannelLogoutHandler}. - * @param logoutHandler the {@link LogoutHandler} to use - */ - void setLogoutHandler(LogoutHandler logoutHandler) { - Assert.notNull(logoutHandler, "logoutHandler cannot be null"); - this.logoutHandler = logoutHandler; - } - } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java index 26ded200034..d7348f5c09d 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandler.java @@ -29,10 +29,9 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.server.ServletServerHttpResponse; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; -import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; import org.springframework.security.oauth2.core.OAuth2Error; @@ -40,6 +39,8 @@ import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.util.UrlUtils; import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestOperations; import org.springframework.web.client.RestTemplate; @@ -51,25 +52,29 @@ * Back-Channel Logout Token and invalidates each one. * * @author Josh Cummings - * @since 6.2 + * @since 6.4 * @see OIDC Back-Channel Logout * Spec */ -final class OidcBackChannelLogoutHandler implements LogoutHandler { +public final class OidcBackChannelLogoutHandler implements LogoutHandler { private final Log logger = LogFactory.getLog(getClass()); - private OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + private final OidcSessionRegistry sessionRegistry; private RestOperations restOperations = new RestTemplate(); - private String logoutUri = "{baseScheme}://localhost{basePort}/logout"; + private String logoutUri = "{baseUrl}/logout/connect/back-channel/{registrationId}"; private String sessionCookieName = "JSESSIONID"; private final OAuth2ErrorHttpMessageConverter errorHttpMessageConverter = new OAuth2ErrorHttpMessageConverter(); + public OidcBackChannelLogoutHandler(OidcSessionRegistry sessionRegistry) { + this.sessionRegistry = sessionRegistry; + } + @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) { @@ -86,7 +91,7 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut for (OidcSessionInformation session : sessions) { totalCount++; try { - eachLogout(request, session); + eachLogout(request, token, session); invalidatedCount++; } catch (RestClientException ex) { @@ -103,18 +108,23 @@ public void logout(HttpServletRequest request, HttpServletResponse response, Aut } } - private void eachLogout(HttpServletRequest request, OidcSessionInformation session) { + private void eachLogout(HttpServletRequest request, OidcBackChannelLogoutAuthentication token, + OidcSessionInformation session) { HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId()); for (Map.Entry credential : session.getAuthorities().entrySet()) { headers.add(credential.getKey(), credential.getValue()); } - String logout = computeLogoutEndpoint(request); - HttpEntity entity = new HttpEntity<>(null, headers); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String logout = computeLogoutEndpoint(request, token); + MultiValueMap body = new LinkedMultiValueMap(); + body.add("logout_token", token.getPrincipal().getTokenValue()); + body.add("_spring_security_internal_logout", "true"); + HttpEntity entity = new HttpEntity<>(body, headers); this.restOperations.postForEntity(logout, entity, Object.class); } - String computeLogoutEndpoint(HttpServletRequest request) { + String computeLogoutEndpoint(HttpServletRequest request, OidcBackChannelLogoutAuthentication token) { // @formatter:off UriComponents uriComponents = UriComponentsBuilder .fromHttpUrl(UrlUtils.buildFullRequestUrl(request)) @@ -137,6 +147,9 @@ String computeLogoutEndpoint(HttpServletRequest request) { int port = uriComponents.getPort(); uriVariables.put("basePort", (port == -1) ? "" : ":" + port); + String registrationId = token.getClientRegistration().getRegistrationId(); + uriVariables.put("registrationId", registrationId); + return UriComponentsBuilder.fromUriString(this.logoutUri) .buildAndExpand(uriVariables) .toUriString(); @@ -158,34 +171,13 @@ private void handleLogoutFailure(HttpServletResponse response, OAuth2Error error } } - /** - * Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that - * this class uses - * {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify - * sessions. - * @param sessionRegistry the {@link OidcSessionRegistry} to use - */ - void setSessionRegistry(OidcSessionRegistry sessionRegistry) { - Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); - this.sessionRegistry = sessionRegistry; - } - - /** - * Use this {@link RestOperations} to perform the per-session back-channel logout - * @param restOperations the {@link RestOperations} to use - */ - void setRestOperations(RestOperations restOperations) { - Assert.notNull(restOperations, "restOperations cannot be null"); - this.restOperations = restOperations; - } - /** * Use this logout URI for performing per-session logout. Defaults to {@code /logout} * since that is the default URI for * {@link org.springframework.security.web.authentication.logout.LogoutFilter}. * @param logoutUri the URI to use */ - void setLogoutUri(String logoutUri) { + public void setLogoutUri(String logoutUri) { Assert.hasText(logoutUri, "logoutUri cannot be empty"); this.logoutUri = logoutUri; } @@ -197,7 +189,7 @@ void setLogoutUri(String logoutUri) { * Note that if you are using Spring Session, this likely needs to change to SESSION. * @param sessionCookieName the cookie name to use */ - void setSessionCookieName(String sessionCookieName) { + public void setSessionCookieName(String sessionCookieName) { Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty"); this.sessionCookieName = sessionCookieName; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java index 38c2e55e6b8..9e3eefc0e7d 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurer.java @@ -19,16 +19,24 @@ import java.util.function.Consumer; import java.util.function.Function; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.context.ApplicationContext; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer; +import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.logout.CompositeLogoutHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.util.Assert; @@ -140,8 +148,11 @@ private AuthenticationManager authenticationManager() { } private LogoutHandler logoutHandler(B http) { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); - logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); + OidcBackChannelLogoutHandler logoutHandler = getBeanOrNull(OidcBackChannelLogoutHandler.class); + if (logoutHandler != null) { + return logoutHandler; + } + logoutHandler = new OidcBackChannelLogoutHandler(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); return logoutHandler; } @@ -176,21 +187,137 @@ private LogoutHandler logoutHandler(B http) { */ public BackChannelLogoutConfigurer logoutUri(String logoutUri) { this.logoutHandler = (http) -> { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); - logoutHandler.setSessionRegistry(OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler( + OAuth2ClientConfigurerUtils.getOidcSessionRegistry(http)); logoutHandler.setLogoutUri(logoutUri); return logoutHandler; }; return this; } + /** + * Configure what and how per-session logout will be performed. + * + *

+ * This overrides any value given to {@link #logoutUri(String)} + * + *

+ * By default, the resulting {@link LogoutHandler} will {@code POST} the session + * cookie and OIDC logout token back to the original back-channel logout endpoint. + * + *

+ * Using this method changes the underlying default that {@code POST}s the session + * cookie and CSRF token to your application's {@code /logout} endpoint. As such, + * it is recommended to call this instead of accepting the {@code /logout} default + * as this does not require any special CSRF configuration, even if you don't + * require other changes. + * + *

+ * For example, configuring Back-Channel Logout in the following way: + * + *

+		 * 	http
+		 *     	.oidcLogout((oidc) -> oidc
+		 *     		.backChannel((backChannel) -> backChannel
+		 *     			.logoutHandler(new OidcBackChannelLogoutHandler())
+		 *     		)
+		 *     	);
+		 * 
+ * + * will make so that the per-session logout invocation no longer requires special + * CSRF configurations. + * + *

+ * The default URI is + * {@code {baseUrl}/logout/connect/back-channel/{registrationId}}, which is simply + * an internal version of the same endpoint exposed to your Back-Channel services. + * You can use {@link OidcBackChannelLogoutHandler#setLogoutUri(String)} to alter + * the scheme, server name, or port in the {@code Host} header to accommodate how + * your application would address itself internally. + * + *

+ * For example, if the way your application would internally call itself is on a + * different scheme and port than incoming traffic, you can configure the endpoint + * in the following way: + * + *

+		 * 	http
+		 * 		.oidcLogout((oidc) -> oidc
+		 * 			.backChannel((backChannel) -> backChannel
+		 * 				.logoutHandler("http://localhost:9000/logout/connect/back-channel/{registrationId}")
+		 * 			)
+		 * 		);
+		 * 
+ * + *

+ * You can also publish it as a {@code @Bean} as follows: + * + *

+		 *	@Bean
+		 *	OidcBackChannelLogoutHandler oidcLogoutHandler(OidcSessionRegistry sessionRegistry) {
+		 *  	OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(sessionRegistry);
+		 *  	logoutHandler.setSessionCookieName("SESSION");
+		 *  	return logoutHandler;
+		 *	}
+		 * 
+ * + * to have the same effect. + * @param logoutHandler the {@link LogoutHandler} to use each individual session + * @return {@link BackChannelLogoutConfigurer} for further customizations + * @since 6.4 + */ + public BackChannelLogoutConfigurer logoutHandler(LogoutHandler logoutHandler) { + this.logoutHandler = (http) -> logoutHandler; + return this; + } + void configure(B http) { + LogoutHandler oidcLogout = this.logoutHandler.apply(http); + LogoutHandler sessionLogout = new SecurityContextLogoutHandler(); + LogoutConfigurer logout = http.getConfigurer(LogoutConfigurer.class); + if (logout != null) { + sessionLogout = new CompositeLogoutHandler(logout.getLogoutHandlers()); + } OidcBackChannelLogoutFilter filter = new OidcBackChannelLogoutFilter(authenticationConverter(http), - authenticationManager()); - filter.setLogoutHandler(this.logoutHandler.apply(http)); + authenticationManager(), new EitherLogoutHandler(oidcLogout, sessionLogout)); http.addFilterBefore(filter, CsrfFilter.class); } + private T getBeanOrNull(Class clazz) { + ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class); + if (context != null) { + String[] names = context.getBeanNamesForType(clazz); + if (names.length == 1) { + return (T) context.getBean(names[0]); + } + } + return null; + } + + private static final class EitherLogoutHandler implements LogoutHandler { + + private final LogoutHandler left; + + private final LogoutHandler right; + + EitherLogoutHandler(LogoutHandler left, LogoutHandler right) { + this.left = left; + this.right = right; + } + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) { + if (request.getParameter("_spring_security_internal_logout") == null) { + this.left.logout(request, response, authentication); + } + else { + this.right.logout(request, response, authentication); + } + } + + } + } } diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java index c68063b6142..f7dd4b2e098 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutAuthentication.java @@ -20,6 +20,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; /** * An {@link org.springframework.security.core.Authentication} implementation that @@ -37,13 +38,16 @@ class OidcBackChannelLogoutAuthentication extends AbstractAuthenticationToken { private final OidcLogoutToken logoutToken; + private final ClientRegistration clientRegistration; + /** * Construct an {@link OidcBackChannelLogoutAuthentication} * @param logoutToken a deserialized, verified OIDC Logout Token */ - OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken) { + OidcBackChannelLogoutAuthentication(OidcLogoutToken logoutToken, ClientRegistration clientRegistration) { super(Collections.emptyList()); this.logoutToken = logoutToken; + this.clientRegistration = clientRegistration; setAuthenticated(true); } @@ -63,4 +67,8 @@ public OidcLogoutToken getCredentials() { return this.logoutToken; } + ClientRegistration getClientRegistration() { + return this.clientRegistration; + } + } diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java index 46e4c442062..f36e48a2cb0 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutReactiveAuthenticationManager.java @@ -80,7 +80,7 @@ public Mono authenticate(Authentication authentication) throws A .map((jwt) -> OidcLogoutToken.withTokenValue(logoutToken) .claims((claims) -> claims.putAll(jwt.getClaims())) .build()) - .map(OidcBackChannelLogoutAuthentication::new); + .map((oidcLogoutToken) -> new OidcBackChannelLogoutAuthentication(oidcLogoutToken, registration)); } private Mono decode(ClientRegistration registration, String token) { diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java index 74f5f32e687..291ea41034a 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelLogoutWebFilter.java @@ -34,7 +34,6 @@ import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.web.authentication.AuthenticationConverter; -import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; @@ -60,7 +59,7 @@ class OidcBackChannelLogoutWebFilter implements WebFilter { private final ReactiveAuthenticationManager authenticationManager; - private ServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + private final ServerLogoutHandler logoutHandler; /** * Construct an {@link OidcBackChannelLogoutWebFilter} @@ -70,11 +69,13 @@ class OidcBackChannelLogoutWebFilter implements WebFilter { * Logout Tokens */ OidcBackChannelLogoutWebFilter(ServerAuthenticationConverter authenticationConverter, - ReactiveAuthenticationManager authenticationManager) { + ReactiveAuthenticationManager authenticationManager, ServerLogoutHandler logoutHandler) { Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.notNull(logoutHandler, "logoutHandler cannot be null"); this.authenticationConverter = authenticationConverter; this.authenticationManager = authenticationManager; + this.logoutHandler = logoutHandler; } @Override @@ -124,14 +125,4 @@ private OAuth2Error oauth2Error(Exception ex) { "https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation"); } - /** - * The strategy for expiring all Client sessions indicated by the logout request. - * Defaults to {@link OidcBackChannelServerLogoutHandler}. - * @param logoutHandler the {@link LogoutHandler} to use - */ - void setLogoutHandler(ServerLogoutHandler logoutHandler) { - Assert.notNull(logoutHandler, "logoutHandler cannot be null"); - this.logoutHandler = logoutHandler; - } - } diff --git a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java index 5312a6da7c4..1fe128fd7e0 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java +++ b/config/src/main/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandler.java @@ -34,15 +34,15 @@ import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.oidc.authentication.logout.OidcLogoutToken; -import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry; import org.springframework.security.oauth2.client.oidc.session.OidcSessionInformation; -import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.web.server.WebFilterExchange; import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler; import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -52,23 +52,27 @@ * Back-Channel Logout Token and invalidates each one. * * @author Josh Cummings - * @since 6.2 + * @since 6.4 * @see OIDC Back-Channel Logout * Spec */ -final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { +public final class OidcBackChannelServerLogoutHandler implements ServerLogoutHandler { private final Log logger = LogFactory.getLog(getClass()); - private ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry(); + private final ReactiveOidcSessionRegistry sessionRegistry; private WebClient web = WebClient.create(); - private String logoutUri = "{baseScheme}://localhost{basePort}/logout"; + private String logoutUri = "{baseUrl}/logout/connect/back-channel/{registrationId}"; private String sessionCookieName = "SESSION"; + public OidcBackChannelServerLogoutHandler(ReactiveOidcSessionRegistry sessionRegistry) { + this.sessionRegistry = sessionRegistry; + } + @Override public Mono logout(WebFilterExchange exchange, Authentication authentication) { if (!(authentication instanceof OidcBackChannelLogoutAuthentication token)) { @@ -84,7 +88,7 @@ public Mono logout(WebFilterExchange exchange, Authentication authenticati AtomicInteger invalidatedCount = new AtomicInteger(0); return this.sessionRegistry.removeSessionInformation(token.getPrincipal()).concatMap((session) -> { totalCount.incrementAndGet(); - return eachLogout(exchange, session).flatMap((response) -> { + return eachLogout(exchange, session, token).flatMap((response) -> { invalidatedCount.incrementAndGet(); return Mono.empty(); }).onErrorResume((ex) -> { @@ -105,17 +109,26 @@ public Mono logout(WebFilterExchange exchange, Authentication authenticati }); } - private Mono> eachLogout(WebFilterExchange exchange, OidcSessionInformation session) { + private Mono> eachLogout(WebFilterExchange exchange, OidcSessionInformation session, + OidcBackChannelLogoutAuthentication token) { HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.COOKIE, this.sessionCookieName + "=" + session.getSessionId()); for (Map.Entry credential : session.getAuthorities().entrySet()) { headers.add(credential.getKey(), credential.getValue()); } - String logout = computeLogoutEndpoint(exchange.getExchange().getRequest()); - return this.web.post().uri(logout).headers((h) -> h.putAll(headers)).retrieve().toBodilessEntity(); + String logout = computeLogoutEndpoint(exchange.getExchange().getRequest(), token); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("logout_token", token.getPrincipal().getTokenValue()); + body.add("_spring_security_internal_logout", "true"); + return this.web.post() + .uri(logout) + .headers((h) -> h.putAll(headers)) + .body(BodyInserters.fromFormData(body)) + .retrieve() + .toBodilessEntity(); } - String computeLogoutEndpoint(ServerHttpRequest request) { + String computeLogoutEndpoint(ServerHttpRequest request, OidcBackChannelLogoutAuthentication token) { // @formatter:off UriComponents uriComponents = UriComponentsBuilder.fromUri(request.getURI()) .replacePath(request.getPath().contextPath().value()) @@ -137,6 +150,9 @@ String computeLogoutEndpoint(ServerHttpRequest request) { int port = uriComponents.getPort(); uriVariables.put("basePort", (port == -1) ? "" : ":" + port); + String registrationId = token.getClientRegistration().getRegistrationId(); + uriVariables.put("registrationId", registrationId); + return UriComponentsBuilder.fromUriString(this.logoutUri) .buildAndExpand(uriVariables) .toUriString(); @@ -161,34 +177,13 @@ private Mono handleLogoutFailure(ServerHttpResponse response, OAuth2Error return response.writeWith(Flux.just(buffer)); } - /** - * Use this {@link OidcSessionRegistry} to identify sessions to invalidate. Note that - * this class uses - * {@link OidcSessionRegistry#removeSessionInformation(OidcLogoutToken)} to identify - * sessions. - * @param sessionRegistry the {@link OidcSessionRegistry} to use - */ - void setSessionRegistry(ReactiveOidcSessionRegistry sessionRegistry) { - Assert.notNull(sessionRegistry, "sessionRegistry cannot be null"); - this.sessionRegistry = sessionRegistry; - } - - /** - * Use this {@link WebClient} to perform the per-session back-channel logout - * @param web the {@link WebClient} to use - */ - void setWebClient(WebClient web) { - Assert.notNull(web, "web cannot be null"); - this.web = web; - } - /** * Use this logout URI for performing per-session logout. Defaults to {@code /logout} * since that is the default URI for * {@link org.springframework.security.web.authentication.logout.LogoutFilter}. * @param logoutUri the URI to use */ - void setLogoutUri(String logoutUri) { + public void setLogoutUri(String logoutUri) { Assert.hasText(logoutUri, "logoutUri cannot be empty"); this.logoutUri = logoutUri; } @@ -200,7 +195,7 @@ void setLogoutUri(String logoutUri) { * Note that if you are using Spring Session, this likely needs to change to SESSION. * @param sessionCookieName the cookie name to use */ - void setSessionCookieName(String sessionCookieName) { + public void setSessionCookieName(String sessionCookieName) { Assert.hasText(sessionCookieName, "clientSessionCookieName cannot be empty"); this.sessionCookieName = sessionCookieName; } diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index ebec42a6ef1..cd52e80738a 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -58,7 +58,6 @@ import org.springframework.security.authorization.ObservationReactiveAuthorizationManager; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; @@ -5497,7 +5496,7 @@ private ReactiveClientRegistrationRepository getClientRegistrationRepository() { private ReactiveOidcSessionRegistry getSessionRegistry() { if (this.sessionRegistry == null && ServerHttpSecurity.this.oauth2Login == null) { - return new InMemoryReactiveOidcSessionRegistry(); + return getBeanOrDefault(ReactiveOidcSessionRegistry.class, new InMemoryReactiveOidcSessionRegistry()); } if (this.sessionRegistry == null) { return ServerHttpSecurity.this.oauth2Login.oidcSessionRegistry; @@ -5529,8 +5528,12 @@ private ReactiveAuthenticationManager authenticationManager() { } private ServerLogoutHandler logoutHandler() { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); - logoutHandler.setSessionRegistry(OidcLogoutSpec.this.getSessionRegistry()); + OidcBackChannelServerLogoutHandler logoutHandler = getBeanOrNull( + OidcBackChannelServerLogoutHandler.class); + if (logoutHandler != null) { + return logoutHandler; + } + logoutHandler = new OidcBackChannelServerLogoutHandler(OidcLogoutSpec.this.getSessionRegistry()); return logoutHandler; } @@ -5548,9 +5551,9 @@ private ServerLogoutHandler logoutHandler() { * *

* By default, the URI is set to - * {@code {baseScheme}://localhost{basePort}/logout}, meaning that the scheme - * and port of the original back-channel request is preserved, while the host - * and endpoint are changed. + * {@code {baseUrl}/logout/connect/back-channel/{registrationId}}, meaning + * that the scheme and port of the original back-channel request is preserved, + * while the host and endpoint are changed. * *

* If you are using Spring Security for the logout endpoint, the path part of @@ -5561,27 +5564,135 @@ private ServerLogoutHandler logoutHandler() { * that the scheme, server name, or port in the {@code Host} header are * different from how you would address the same server internally. * @param logoutUri the URI to request logout on the back-channel - * @return the {@link OidcLogoutConfigurer.BackChannelLogoutConfigurer} for - * further customizations + * @return the {@link BackChannelLogoutConfigurer} for further customizations * @since 6.2.4 */ public BackChannelLogoutConfigurer logoutUri(String logoutUri) { this.logoutHandler = () -> { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); - logoutHandler.setSessionRegistry(OidcLogoutSpec.this.getSessionRegistry()); + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler( + OidcLogoutSpec.this.getSessionRegistry()); logoutHandler.setLogoutUri(logoutUri); return logoutHandler; }; return this; } + /** + * Configure what and how per-session logout will be performed. + * + *

+ * This overrides any value given to {@link #logoutUri(String)} + * + *

+ * By default, the resulting {@link LogoutHandler} will {@code POST} the + * session cookie and OIDC logout token back to the original back-channel + * logout endpoint. + * + *

+ * Using this method changes the underlying default that {@code POST}s the + * session cookie and CSRF token to your application's {@code /logout} + * endpoint. As such, it is recommended to call this instead of accepting the + * {@code /logout} default as this does not require any special CSRF + * configuration, even if you don't require other changes. + * + *

+ * For example, configuring Back-Channel Logout in the following way: + * + *

+			 * 	http
+			 *     	.oidcLogout((oidc) -> oidc
+			 *     		.backChannel((backChannel) -> backChannel
+			 *     			.logoutHandler(new OidcBackChannelServerLogoutHandler())
+			 *     		)
+			 *     	);
+			 * 
+ * + * will make so that the per-session logout invocation no longer requires + * special CSRF configurations. + * + *

+ * The default URI is + * {@code {baseUrl}/logout/connect/back-channel/{registrationId}}, which is + * simply an internal version of the same endpoint exposed to your + * Back-Channel services. You can use + * {@link OidcBackChannelServerLogoutHandler#setLogoutUri(String)} to alter + * the scheme, server name, or port in the {@code Host} header to accommodate + * how your application would address itself internally. + * + *

+ * For example, if the way your application would internally call itself is on + * a different scheme and port than incoming traffic, you can configure the + * endpoint in the following way: + * + *

+			 * 	http
+			 * 		.oidcLogout((oidc) -> oidc
+			 * 			.backChannel((backChannel) -> backChannel
+			 * 				.logoutUri("http://localhost:9000/logout/connect/back-channel/{registrationId}")
+			 * 			)
+			 * 		);
+			 * 
+ * + *

+ * You can also publish it as a {@code @Bean} as follows: + * + *

+			 *	@Bean
+			 *	OidcBackChannelServerLogoutHandler oidcLogoutHandler() {
+			 *  	OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler();
+			 *  	logoutHandler.setLogoutUri("http://localhost:9000/logout/connect/back-channel/{registrationId}");
+			 *  	return logoutHandler;
+			 *	}
+			 * 
+ * + * to have the same effect. + * @param logoutHandler the {@link ServerLogoutHandler} to use each individual + * session + * @return {@link BackChannelLogoutConfigurer} for further customizations + * @since 6.4 + */ + public BackChannelLogoutConfigurer logoutHandler(ServerLogoutHandler logoutHandler) { + this.logoutHandler = () -> logoutHandler; + return this; + } + void configure(ServerHttpSecurity http) { + ServerLogoutHandler oidcLogout = this.logoutHandler.get(); + ServerLogoutHandler sessionLogout = new SecurityContextServerLogoutHandler(); + LogoutSpec logout = ServerHttpSecurity.this.logout; + if (logout != null) { + sessionLogout = new DelegatingServerLogoutHandler(logout.logoutHandlers); + } OidcBackChannelLogoutWebFilter filter = new OidcBackChannelLogoutWebFilter(authenticationConverter(), - authenticationManager()); - filter.setLogoutHandler(this.logoutHandler.get()); + authenticationManager(), new EitherLogoutHandler(oidcLogout, sessionLogout)); http.addFilterBefore(filter, SecurityWebFiltersOrder.CSRF); } + private static final class EitherLogoutHandler implements ServerLogoutHandler { + + private final ServerLogoutHandler left; + + private final ServerLogoutHandler right; + + EitherLogoutHandler(ServerLogoutHandler left, ServerLogoutHandler right) { + this.left = left; + this.right = right; + } + + @Override + public Mono logout(WebFilterExchange exchange, Authentication authentication) { + return exchange.getExchange().getFormData().flatMap((data) -> { + if (data.getFirst("_spring_security_internal_logout") == null) { + return this.left.logout(exchange, authentication); + } + else { + return this.right.logout(exchange, authentication); + } + }); + } + + } + } } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2LoginDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2LoginDsl.kt index 538c68ee116..8f151bfc8b4 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2LoginDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OAuth2LoginDsl.kt @@ -16,19 +16,20 @@ package org.springframework.security.config.annotation.web +import jakarta.servlet.http.HttpServletRequest import org.springframework.security.authentication.AuthenticationDetailsSource import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer import org.springframework.security.config.annotation.web.oauth2.login.AuthorizationEndpointDsl import org.springframework.security.config.annotation.web.oauth2.login.RedirectionEndpointDsl import org.springframework.security.config.annotation.web.oauth2.login.TokenEndpointDsl import org.springframework.security.config.annotation.web.oauth2.login.UserInfoEndpointDsl -import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository import org.springframework.security.web.authentication.AuthenticationFailureHandler import org.springframework.security.web.authentication.AuthenticationSuccessHandler -import jakarta.servlet.http.HttpServletRequest /** * A Kotlin DSL to configure [HttpSecurity] OAuth 2.0 login using idiomatic Kotlin code. @@ -61,6 +62,7 @@ class OAuth2LoginDsl { var loginProcessingUrl: String? = null var permitAll: Boolean? = null var authenticationDetailsSource: AuthenticationDetailsSource? = null + var oidcSessionRegistry: OidcSessionRegistry? = null private var defaultSuccessUrlOption: Pair? = null private var authorizationEndpoint: ((OAuth2LoginConfigurer.AuthorizationEndpointConfig) -> Unit)? = null @@ -236,6 +238,7 @@ class OAuth2LoginDsl { redirectionEndpoint?.also { oauth2Login.redirectionEndpoint(redirectionEndpoint) } userInfoEndpoint?.also { oauth2Login.userInfoEndpoint(userInfoEndpoint) } authenticationDetailsSource?.also { oauth2Login.authenticationDetailsSource(authenticationDetailsSource) } + oidcSessionRegistry?.also { oauth2Login.oidcSessionRegistry(oidcSessionRegistry) } } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt index f9fdd7dc4dd..27532b4c015 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDsl.kt @@ -1,3 +1,4 @@ + /* * Copyright 2002-2023 the original author or authors. * @@ -72,4 +73,5 @@ class OidcLogoutDsl { backChannel?.also { oidcLogout.backChannel(backChannel) } } } + } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt index efac77a5667..f23b14d9811 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/oauth2/login/OidcBackChannelLogoutDsl.kt @@ -18,6 +18,7 @@ package org.springframework.security.config.annotation.web.oauth2.login import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer +import org.springframework.security.web.authentication.logout.LogoutHandler /** * A Kotlin DSL to configure the OIDC 1.0 Back-Channel configuration using @@ -28,7 +29,26 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.cli */ @OAuth2LoginSecurityMarker class OidcBackChannelLogoutDsl { + private var _logoutUri: String? = null + private var _logoutHandler: LogoutHandler? = null + + var logoutHandler: LogoutHandler? + get() = _logoutHandler + set(value) { + _logoutHandler = value + _logoutUri = null + } + var logoutUri: String? + get() = _logoutUri + set(value) { + _logoutUri = value + _logoutHandler = null + } + internal fun get(): (OidcLogoutConfigurer.BackChannelLogoutConfigurer) -> Unit { - return { backChannel -> } + return { backChannel -> + logoutHandler?.also { backChannel.logoutHandler(logoutHandler) } + logoutUri?.also { backChannel.logoutUri(logoutUri) } + } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOAuth2LoginDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOAuth2LoginDsl.kt index 0aa91e48d5e..6050c8bc039 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOAuth2LoginDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOAuth2LoginDsl.kt @@ -19,6 +19,7 @@ package org.springframework.security.config.web.server import org.springframework.security.authentication.ReactiveAuthenticationManager import org.springframework.security.core.Authentication import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientService +import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository import org.springframework.security.oauth2.client.web.server.ServerAuthorizationRequestRepository import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizationRequestResolver @@ -70,6 +71,7 @@ class ServerOAuth2LoginDsl { var authorizationRedirectStrategy: ServerRedirectStrategy? = null var authenticationMatcher: ServerWebExchangeMatcher? = null var loginPage: String? = null + var oidcSessionRegistry: ReactiveOidcSessionRegistry? = null internal fun get(): (ServerHttpSecurity.OAuth2LoginSpec) -> Unit { return { oauth2Login -> @@ -86,6 +88,7 @@ class ServerOAuth2LoginDsl { authorizationRedirectStrategy?.also { oauth2Login.authorizationRedirectStrategy(authorizationRedirectStrategy) } authenticationMatcher?.also { oauth2Login.authenticationMatcher(authenticationMatcher) } loginPage?.also { oauth2Login.loginPage(loginPage) } + oidcSessionRegistry?.also { oauth2Login.oidcSessionRegistry(oidcSessionRegistry) } } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt index 5a245e5092e..ba6b15da933 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcBackChannelLogoutDsl.kt @@ -16,6 +16,8 @@ package org.springframework.security.config.web.server +import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler + /** * A Kotlin DSL to configure [ServerHttpSecurity] OIDC 1.0 Back-Channel Logout support using idiomatic Kotlin code. * @@ -24,7 +26,26 @@ package org.springframework.security.config.web.server */ @ServerSecurityMarker class ServerOidcBackChannelLogoutDsl { + private var _logoutUri: String? = null + private var _logoutHandler: ServerLogoutHandler? = null + + var logoutHandler: ServerLogoutHandler? + get() = _logoutHandler + set(value) { + _logoutHandler = value + _logoutUri = null + } + var logoutUri: String? + get() = _logoutUri + set(value) { + _logoutUri = value + _logoutHandler = null + } + internal fun get(): (ServerHttpSecurity.OidcLogoutSpec.BackChannelLogoutConfigurer) -> Unit { - return { backChannel -> } + return { backChannel -> + logoutHandler?.also { backChannel.logoutHandler(logoutHandler) } + logoutUri?.also { backChannel.logoutUri(logoutUri) } + } } } diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt index 503a5b0c843..7c27e3e0812 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDsl.kt @@ -47,7 +47,9 @@ class ServerOidcLogoutDsl { * return http { * oauth2Login { } * oidcLogout { - * backChannel { } + * backChannel { + * sessionLogout { } + * } * } * } * } diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java index c6d0afb3b18..b82eb985bc9 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/PrePostReactiveMethodSecurityConfigurationTests.java @@ -16,27 +16,64 @@ package org.springframework.security.config.annotation.method.configuration; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import jakarta.annotation.security.DenyAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import org.springframework.aop.config.AopConfigUtils; +import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Role; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.PermissionEvaluator; +import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; +import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.access.prepost.PostFilter; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.access.prepost.PreFilter; import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.authorization.method.AuthorizationAdvisor; +import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory; +import org.springframework.security.authorization.method.AuthorizeReturnObject; +import org.springframework.security.authorization.method.PrePostTemplateDefaults; +import org.springframework.security.config.Customizer; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults; import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.stereotype.Component; import org.springframework.test.context.junit.jupiter.SpringExtension; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -228,6 +265,216 @@ public void preAuthorizeWhenCustomMethodSecurityExpressionHandlerThenUses() { verify(permissionEvaluator, times(2)).hasPermission(any(), any(), any()); } + @ParameterizedTest + @ValueSource(classes = { LegacyMetaAnnotationPlaceholderConfig.class, MetaAnnotationPlaceholderConfig.class }) + @WithMockUser + public void methodeWhenParameterizedPreAuthorizeMetaAnnotationThenPasses(Class config) { + this.spring.register(config).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + assertThat(service.hasRole("USER").block()).isTrue(); + } + + @ParameterizedTest + @ValueSource(classes = { LegacyMetaAnnotationPlaceholderConfig.class, MetaAnnotationPlaceholderConfig.class }) + @WithMockUser + public void methodRoleWhenPreAuthorizeMetaAnnotationHardcodedParameterThenPasses(Class config) { + this.spring.register(config).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + assertThat(service.hasUserRole().block()).isTrue(); + } + + @ParameterizedTest + @ValueSource(classes = { LegacyMetaAnnotationPlaceholderConfig.class, MetaAnnotationPlaceholderConfig.class }) + public void methodWhenParameterizedAnnotationThenFails(Class config) { + this.spring.register(config).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> service.placeholdersOnlyResolvedByMetaAnnotations().block()); + } + + @ParameterizedTest + @ValueSource(classes = { LegacyMetaAnnotationPlaceholderConfig.class, MetaAnnotationPlaceholderConfig.class }) + @WithMockUser(authorities = "SCOPE_message:read") + public void methodWhenMultiplePlaceholdersHasAuthorityThenPasses(Class config) { + this.spring.register(config).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + assertThat(service.readMessage().block()).isEqualTo("message"); + } + + @ParameterizedTest + @ValueSource(classes = { LegacyMetaAnnotationPlaceholderConfig.class, MetaAnnotationPlaceholderConfig.class }) + @WithMockUser(roles = "ADMIN") + public void methodWhenMultiplePlaceholdersHasRoleThenPasses(Class config) { + this.spring.register(config).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + assertThat(service.readMessage().block()).isEqualTo("message"); + } + + @ParameterizedTest + @ValueSource(classes = { LegacyMetaAnnotationPlaceholderConfig.class, MetaAnnotationPlaceholderConfig.class }) + @WithMockUser + public void methodWhenPostAuthorizeMetaAnnotationThenAuthorizes(Class config) { + this.spring.register(config).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + service.startsWithDave("daveMatthews"); + assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(() -> service.startsWithDave("jenniferHarper").block()); + } + + @ParameterizedTest + @ValueSource(classes = { LegacyMetaAnnotationPlaceholderConfig.class, MetaAnnotationPlaceholderConfig.class }) + @WithMockUser + public void methodWhenPreFilterMetaAnnotationThenFilters(Class config) { + this.spring.register(config).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + assertThat(service.parametersContainDave(Flux.just("dave", "carla", "vanessa", "paul")).collectList().block()) + .containsExactly("dave"); + } + + @ParameterizedTest + @ValueSource(classes = { LegacyMetaAnnotationPlaceholderConfig.class, MetaAnnotationPlaceholderConfig.class }) + @WithMockUser + public void methodWhenPostFilterMetaAnnotationThenFilters(Class config) { + this.spring.register(config).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + assertThat(service.resultsContainDave(Flux.just("dave", "carla", "vanessa", "paul")).collectList().block()) + .containsExactly("dave"); + } + + @Test + @WithMockUser(authorities = "airplane:read") + public void findByIdWhenAuthorizedResultThenAuthorizes() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + Flight flight = flights.findById("1").block(); + assertThatNoException().isThrownBy(flight::getAltitude); + assertThatNoException().isThrownBy(flight::getSeats); + } + + @Test + @WithMockUser(authorities = "seating:read") + public void findByIdWhenUnauthorizedResultThenDenies() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + Flight flight = flights.findById("1").block(); + assertThatNoException().isThrownBy(flight::getSeats); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> flight.getAltitude().block()); + } + + @Test + @WithMockUser(authorities = "seating:read") + public void findAllWhenUnauthorizedResultThenDenies() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.findAll().collectList().block().forEach((flight) -> { + assertThatNoException().isThrownBy(flight::getSeats); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(() -> flight.getAltitude().block()); + }); + } + + @Test + public void removeWhenAuthorizedResultThenRemoves() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.remove("1"); + } + + @Test + @WithMockUser(authorities = "airplane:read") + public void findAllWhenPostFilterThenFilters() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.findAll() + .collectList() + .block() + .forEach((flight) -> assertThat(flight.getPassengers().collectList().block()) + .extracting((p) -> p.getName().block()) + .doesNotContain("Kevin Mitnick")); + } + + @Test + @WithMockUser(authorities = "airplane:read") + public void findAllWhenPreFilterThenFilters() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.findAll().collectList().block().forEach((flight) -> { + flight.board(Flux.just("John")).block(); + assertThat(flight.getPassengers().collectList().block()).extracting((p) -> p.getName().block()) + .doesNotContain("John"); + flight.board(Flux.just("John Doe")).block(); + assertThat(flight.getPassengers().collectList().block()).extracting((p) -> p.getName().block()) + .contains("John Doe"); + }); + } + + @Test + @WithMockUser(authorities = "seating:read") + public void findAllWhenNestedPreAuthorizeThenAuthorizes() { + this.spring.register(AuthorizeResultConfig.class).autowire(); + FlightRepository flights = this.spring.getContext().getBean(FlightRepository.class); + flights.findAll().collectList().block().forEach((flight) -> { + List passengers = flight.getPassengers().collectList().block(); + passengers.forEach((passenger) -> assertThatExceptionOfType(AccessDeniedException.class) + .isThrownBy(() -> passenger.getName().block())); + }); + } + + // gh-15352 + @Test + void annotationsInChildClassesDoNotAffectSuperclasses() { + this.spring.register(AbstractClassConfig.class).autowire(); + this.spring.getContext().getBean(ClassInheritingAbstractClassWithNoAnnotations.class).method(); + } + + // gh-15592 + @Test + void autowireWhenDefaultsThenCreatesExactlyOneAdvisorPerAnnotation() { + this.spring.register(MethodSecurityServiceEnabledConfig.class).autowire(); + AuthorizationAdvisorProxyFactory proxyFactory = this.spring.getContext() + .getBean(AuthorizationAdvisorProxyFactory.class); + assertThat(proxyFactory).hasSize(5); + assertThat(this.spring.getContext().getBeanNamesForType(AuthorizationAdvisor.class)).hasSize(5) + .containsExactlyInAnyOrder("preFilterAuthorizationMethodInterceptor", + "preAuthorizeAuthorizationMethodInterceptor", "postAuthorizeAuthorizationMethodInterceptor", + "postFilterAuthorizationMethodInterceptor", "authorizeReturnObjectMethodInterceptor"); + } + + // gh-15592 + @Test + void autowireWhenAspectJAutoProxyAndFactoryBeanThenExactlyOneAdvisorPerAnnotation() { + this.spring.register(AspectJAwareAutoProxyAndFactoryBeansConfig.class).autowire(); + AuthorizationAdvisorProxyFactory proxyFactory = this.spring.getContext() + .getBean(AuthorizationAdvisorProxyFactory.class); + assertThat(proxyFactory).hasSize(5); + assertThat(this.spring.getContext().getBeanNamesForType(AuthorizationAdvisor.class)).hasSize(5) + .containsExactlyInAnyOrder("preFilterAuthorizationMethodInterceptor", + "preAuthorizeAuthorizationMethodInterceptor", "postAuthorizeAuthorizationMethodInterceptor", + "postFilterAuthorizationMethodInterceptor", "authorizeReturnObjectMethodInterceptor"); + } + + // gh-15651 + @Test + @WithMockUser(roles = "ADMIN") + public void adviseWhenPrePostEnabledThenEachInterceptorRunsExactlyOnce() { + this.spring + .register(MethodSecurityServiceEnabledConfig.class, CustomMethodSecurityExpressionHandlerConfig.class) + .autowire(); + MethodSecurityExpressionHandler expressionHandler = this.spring.getContext() + .getBean(MethodSecurityExpressionHandler.class); + ReactiveMethodSecurityService service = this.spring.getContext().getBean(ReactiveMethodSecurityService.class); + service.manyAnnotations(Mono.just(new ArrayList<>(Arrays.asList("harold", "jonathan", "tim", "bo")))).block(); + verify(expressionHandler, times(4)).createEvaluationContext(any(Authentication.class), any()); + } + + // gh-15721 + @Test + @WithMockUser(roles = "uid") + public void methodWhenMetaAnnotationPropertiesHasClassProperties() { + this.spring.register(MetaAnnotationPlaceholderConfig.class).autowire(); + MetaAnnotationService service = this.spring.getContext().getBean(MetaAnnotationService.class); + assertThat(service.getIdPath("uid").block()).isEqualTo("uid"); + } + @Configuration @EnableReactiveMethodSecurity static class MethodSecurityServiceEnabledConfig { @@ -239,6 +486,20 @@ ReactiveMethodSecurityService methodSecurityService() { } + @Configuration + @EnableReactiveMethodSecurity + static class CustomMethodSecurityExpressionHandlerConfig { + + private final MethodSecurityExpressionHandler expressionHandler = spy( + new DefaultMethodSecurityExpressionHandler()); + + @Bean + MethodSecurityExpressionHandler methodSecurityExpressionHandler() { + return this.expressionHandler; + } + + } + @Configuration static class PermissionEvaluatorConfig { @@ -258,4 +519,322 @@ static DefaultMethodSecurityExpressionHandler methodSecurityExpressionHandler( } + @Configuration + @EnableReactiveMethodSecurity + static class LegacyMetaAnnotationPlaceholderConfig { + + @Bean + PrePostTemplateDefaults methodSecurityDefaults() { + return new PrePostTemplateDefaults(); + } + + @Bean + MetaAnnotationService metaAnnotationService() { + return new MetaAnnotationService(); + } + + } + + @Configuration + @EnableReactiveMethodSecurity + static class MetaAnnotationPlaceholderConfig { + + @Bean + AnnotationTemplateExpressionDefaults methodSecurityDefaults() { + return new AnnotationTemplateExpressionDefaults(); + } + + @Bean + MetaAnnotationService metaAnnotationService() { + return new MetaAnnotationService(); + } + + } + + static class MetaAnnotationService { + + @RequireRole(role = "#role") + Mono hasRole(String role) { + return Mono.just(true); + } + + @RequireRole(role = "'USER'") + Mono hasUserRole() { + return Mono.just(true); + } + + @PreAuthorize("hasRole({role})") + Mono placeholdersOnlyResolvedByMetaAnnotations() { + return Mono.empty(); + } + + @HasClaim(claim = "message:read", roles = { "'ADMIN'" }) + Mono readMessage() { + return Mono.just("message"); + } + + @ResultStartsWith("dave") + Mono startsWithDave(String value) { + return Mono.just(value); + } + + @ParameterContains("dave") + Flux parametersContainDave(Flux list) { + return list; + } + + @ResultContains("dave") + Flux resultsContainDave(Flux list) { + return list; + } + + @RestrictedAccess(entityClass = EntityClass.class) + Mono getIdPath(String id) { + return Mono.just(id); + } + + } + + @Retention(RetentionPolicy.RUNTIME) + @PreAuthorize("hasRole({idPath})") + @interface RestrictedAccess { + + String idPath() default "#id"; + + Class entityClass(); + + String[] recipes() default {}; + + } + + static class EntityClass { + + } + + @Retention(RetentionPolicy.RUNTIME) + @PreAuthorize("hasRole({role})") + @interface RequireRole { + + String role(); + + } + + @Retention(RetentionPolicy.RUNTIME) + @PreAuthorize("hasAuthority('SCOPE_{claim}') || hasAnyRole({roles})") + @interface HasClaim { + + String claim(); + + String[] roles() default {}; + + } + + @Retention(RetentionPolicy.RUNTIME) + @PostAuthorize("returnObject.startsWith('{value}')") + @interface ResultStartsWith { + + String value(); + + } + + @Retention(RetentionPolicy.RUNTIME) + @PreFilter("filterObject.contains('{value}')") + @interface ParameterContains { + + String value(); + + } + + @Retention(RetentionPolicy.RUNTIME) + @PostFilter("filterObject.contains('{value}')") + @interface ResultContains { + + String value(); + + } + + @EnableReactiveMethodSecurity + @Configuration + public static class AuthorizeResultConfig { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Customizer skipValueTypes() { + return (f) -> f.setTargetVisitor(AuthorizationAdvisorProxyFactory.TargetVisitor.defaultsSkipValueTypes()); + } + + @Bean + FlightRepository flights() { + FlightRepository flights = new FlightRepository(); + Flight one = new Flight("1", 35000d, 35); + one.board(Flux.just("Marie Curie", "Kevin Mitnick", "Ada Lovelace")).block(); + flights.save(one).block(); + Flight two = new Flight("2", 32000d, 72); + two.board(Flux.just("Albert Einstein")).block(); + flights.save(two).block(); + return flights; + } + + @Bean + static MethodSecurityExpressionHandler expressionHandler() { + RoleHierarchy hierarchy = RoleHierarchyImpl.withRolePrefix("") + .role("airplane:read") + .implies("seating:read") + .build(); + DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler(); + expressionHandler.setRoleHierarchy(hierarchy); + return expressionHandler; + } + + @Bean + Authz authz() { + return new Authz(); + } + + public static class Authz { + + public Mono isNotKevinMitnick(Passenger passenger) { + return passenger.getName().map((n) -> !"Kevin Mitnick".equals(n)); + } + + } + + } + + @AuthorizeReturnObject + static class FlightRepository { + + private final Map flights = new ConcurrentHashMap<>(); + + Flux findAll() { + return Flux.fromIterable(this.flights.values()); + } + + Mono findById(String id) { + return Mono.just(this.flights.get(id)); + } + + Mono save(Flight flight) { + this.flights.put(flight.getId(), flight); + return Mono.just(flight); + } + + Mono remove(String id) { + this.flights.remove(id); + return Mono.empty(); + } + + } + + @AuthorizeReturnObject + static class Flight { + + private final String id; + + private final Double altitude; + + private final Integer seats; + + private final List passengers = new ArrayList<>(); + + Flight(String id, Double altitude, Integer seats) { + this.id = id; + this.altitude = altitude; + this.seats = seats; + } + + String getId() { + return this.id; + } + + @PreAuthorize("hasAuthority('airplane:read')") + Mono getAltitude() { + return Mono.just(this.altitude); + } + + @PreAuthorize("hasAuthority('seating:read')") + Mono getSeats() { + return Mono.just(this.seats); + } + + @PostAuthorize("hasAuthority('seating:read')") + @PostFilter("@authz.isNotKevinMitnick(filterObject)") + Flux getPassengers() { + return Flux.fromIterable(this.passengers); + } + + @PreAuthorize("hasAuthority('seating:read')") + @PreFilter("filterObject.contains(' ')") + Mono board(Flux passengers) { + return passengers.doOnNext((passenger) -> this.passengers.add(new Passenger(passenger))).then(Mono.empty()); + } + + } + + public static class Passenger { + + String name; + + public Passenger(String name) { + this.name = name; + } + + @PreAuthorize("hasAuthority('airplane:read')") + public Mono getName() { + return Mono.just(this.name); + } + + } + + abstract static class AbstractClassWithNoAnnotations { + + Mono method() { + return Mono.just("ok"); + } + + } + + @PreAuthorize("denyAll()") + @Secured("DENIED") + @DenyAll + static class ClassInheritingAbstractClassWithNoAnnotations extends AbstractClassWithNoAnnotations { + + } + + @EnableReactiveMethodSecurity + static class AbstractClassConfig { + + @Bean + ClassInheritingAbstractClassWithNoAnnotations inheriting() { + return new ClassInheritingAbstractClassWithNoAnnotations(); + } + + } + + @Configuration + @EnableReactiveMethodSecurity + static class AspectJAwareAutoProxyAndFactoryBeansConfig { + + @Bean + static BeanDefinitionRegistryPostProcessor beanDefinitionRegistryPostProcessor() { + return AopConfigUtils::registerAspectJAnnotationAutoProxyCreatorIfNecessary; + } + + @Component + static class MyFactoryBean implements FactoryBean { + + @Override + public Object getObject() throws Exception { + return new Object(); + } + + @Override + public Class getObjectType() { + return Object.class; + } + + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java index e2c3bef113d..a3fee081025 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityService.java @@ -21,6 +21,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.util.List; import org.aopalliance.intercept.MethodInvocation; import reactor.core.publisher.Mono; @@ -31,7 +32,9 @@ import org.springframework.expression.Expression; import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.access.prepost.PostFilter; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.access.prepost.PreFilter; import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.authorization.method.HandleAuthorizationDenied; import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler; @@ -104,6 +107,12 @@ public interface ReactiveMethodSecurityService { @PreAuthorize("hasPermission(#kgName, 'read')") Mono preAuthorizeHasPermission(String kgName); + @PreAuthorize("hasRole('ADMIN')") + @PostAuthorize("hasRole('ADMIN')") + @PreFilter("true") + @PostFilter("true") + Mono> manyAnnotations(Mono> array); + class StarMaskingHandler implements MethodAuthorizationDeniedHandler { @Override diff --git a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java index 3787556a878..7b8a893d171 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java +++ b/config/src/test/java/org/springframework/security/config/annotation/method/configuration/ReactiveMethodSecurityServiceImpl.java @@ -16,6 +16,8 @@ package org.springframework.security.config.annotation.method.configuration; +import java.util.List; + import reactor.core.publisher.Mono; import org.springframework.security.authorization.AuthorizationDecision; @@ -93,4 +95,9 @@ public Mono preAuthorizeHasPermission(String kgName) { return Mono.just("ok"); } + @Override + public Mono> manyAnnotations(Mono> array) { + return array; + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandlerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandlerTests.java index 71595ac7aa7..9dc542a406b 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandlerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcBackChannelLogoutHandlerTests.java @@ -19,44 +19,55 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; +import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; import static org.assertj.core.api.Assertions.assertThat; public class OidcBackChannelLogoutHandlerTests { + private final OidcSessionRegistry sessionRegistry = new InMemoryOidcSessionRegistry(); + + private final OidcBackChannelLogoutAuthentication token = new OidcBackChannelLogoutAuthentication( + TestOidcLogoutTokens.withSubject("issuer", "subject").build(), + TestClientRegistrations.clientRegistration().build()); + // gh-14553 @Test public void computeLogoutEndpointWhenDifferentHostnameThenLocalhost() { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(this.sessionRegistry); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/back-channel/logout"); + logoutHandler.setLogoutUri("{baseScheme}://localhost{basePort}/logout"); request.setServerName("host.docker.internal"); request.setServerPort(8090); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://localhost:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://localhost:8090/logout"); } @Test public void computeLogoutEndpointWhenUsingBaseUrlTemplateThenServerName() { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(this.sessionRegistry); logoutHandler.setLogoutUri("{baseUrl}/logout"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/back-channel/logout"); request.setServerName("host.docker.internal"); request.setServerPort(8090); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://host.docker.internal:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://host.docker.internal:8090/logout"); } // gh-14609 @Test public void computeLogoutEndpointWhenLogoutUriThenUses() { - OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(); + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(this.sessionRegistry); logoutHandler.setLogoutUri("http://localhost:8090/logout"); MockHttpServletRequest request = new MockHttpServletRequest("GET", "/back-channel/logout"); request.setScheme("https"); request.setServerName("server-one.com"); request.setServerPort(80); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://localhost:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://localhost:8090/logout"); } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java index 481dcced742..2b85cb72a26 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OidcLogoutConfigurerTests.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; @@ -91,6 +92,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.willThrow; @@ -218,6 +220,40 @@ void logoutWhenRemoteLogoutUriThenUses() throws Exception { this.mvc.perform(get("/token/logout").session(one)).andExpect(status().isOk()); } + @Test + void logoutWhenSelfRemoteLogoutUriThenUses() throws Exception { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, SelfLogoutUriConfig.class).autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + MockHttpSession session = login(); + String logoutToken = this.mvc.perform(get("/token/logout").session(session)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + this.mvc + .perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .param("logout_token", logoutToken)) + .andExpect(status().isOk()); + this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized()); + } + + @Test + void logoutWhenDifferentCookieNameThenUses() throws Exception { + this.spring.register(OidcProviderConfig.class, CookieConfig.class).autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + MockHttpSession session = login(); + String logoutToken = this.mvc.perform(get("/token/logout").session(session)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + this.mvc + .perform(post(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .param("logout_token", logoutToken)) + .andExpect(status().isOk()); + this.mvc.perform(get("/token/logout").session(session)).andExpect(status().isUnauthorized()); + } + @Test void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() throws Exception { this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire(); @@ -355,6 +391,83 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { } + @Configuration + @EnableWebSecurity + @Import(RegistrationConfig.class) + static class SelfLogoutUriConfig { + + @Bean + @Order(1) + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .oauth2Login(Customizer.withDefaults()) + .oidcLogout((oidc) -> oidc + .backChannel(Customizer.withDefaults()) + ); + // @formatter:on + + return http.build(); + } + + } + + @Configuration + @EnableWebSecurity + @Import(RegistrationConfig.class) + static class CookieConfig { + + private final MockWebServer server = new MockWebServer(); + + @Bean + @Order(1) + SecurityFilterChain filters(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .oauth2Login(Customizer.withDefaults()) + .oidcLogout((oidc) -> oidc + .backChannel(Customizer.withDefaults()) + ); + // @formatter:on + + return http.build(); + } + + @Bean + OidcSessionRegistry sessionRegistry() { + return new InMemoryOidcSessionRegistry(); + } + + @Bean + OidcBackChannelLogoutHandler oidcLogoutHandler(OidcSessionRegistry sessionRegistry) { + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(sessionRegistry); + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; + } + + @Bean + MockWebServer web(ObjectProvider mvc) { + MockMvcDispatcher dispatcher = new MockMvcDispatcher(mvc); + dispatcher.setAssertion((rr) -> { + String cookie = rr.getHeaders().get("Cookie"); + if (cookie == null) { + return; + } + assertThat(cookie).contains("SESSION").doesNotContain("JSESSIONID"); + }); + this.server.setDispatcher(dispatcher); + return this.server; + } + + @PreDestroy + void shutdown() throws IOException { + this.server.shutdown(); + } + + } + @Configuration @EnableWebSecurity @Import(RegistrationConfig.class) @@ -368,7 +481,7 @@ SecurityFilterChain filters(HttpSecurity http) throws Exception { // @formatter:off http .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) - .oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry)) + .oauth2Login(Customizer.withDefaults()) .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); // @formatter:on @@ -559,12 +672,15 @@ private static class MockMvcDispatcher extends Dispatcher { private MockMvc mvc; + private Consumer assertion = (rr) -> { }; + MockMvcDispatcher(ObjectProvider mvc) { this.mvcProvider = mvc; } @Override public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + this.assertion.accept(request); this.mvc = this.mvcProvider.getObject(); String method = request.getMethod(); String path = request.getPath(); @@ -601,6 +717,10 @@ void registerSession(MockHttpSession session) { this.session.put(session.getId(), session); } + void setAssertion(Consumer assertion) { + this.assertion = assertion; + } + private MockHttpSession session(RecordedRequest request) { String cookieHeaderValue = request.getHeader("Cookie"); if (cookieHeaderValue == null) { @@ -613,6 +733,10 @@ private MockHttpSession session(RecordedRequest request) { return this.session.computeIfAbsent(parts[1], (k) -> new MockHttpSession(new MockServletContext(), parts[1])); } + if ("SESSION".equals(parts[0])) { + return this.session.computeIfAbsent(parts[1], + (k) -> new MockHttpSession(new MockServletContext(), parts[1])); + } } return new MockHttpSession(); } diff --git a/config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java b/config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java index 37190608c1d..a8494bdc909 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/OidcBackChannelServerLogoutHandlerTests.java @@ -19,6 +19,10 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.security.oauth2.client.oidc.authentication.logout.TestOidcLogoutTokens; +import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.oidc.server.session.ReactiveOidcSessionRegistry; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; import static org.assertj.core.api.Assertions.assertThat; @@ -27,36 +31,43 @@ */ public class OidcBackChannelServerLogoutHandlerTests { + private final ReactiveOidcSessionRegistry sessionRegistry = new InMemoryReactiveOidcSessionRegistry(); + + private final OidcBackChannelLogoutAuthentication token = new OidcBackChannelLogoutAuthentication( + TestOidcLogoutTokens.withSubject("issuer", "subject").build(), + TestClientRegistrations.clientRegistration().build()); + // gh-14553 @Test public void computeLogoutEndpointWhenDifferentHostnameThenLocalhost() { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(this.sessionRegistry); + logoutHandler.setLogoutUri("{baseScheme}://localhost{basePort}/logout"); MockServerHttpRequest request = MockServerHttpRequest .get("https://host.docker.internal:8090/back-channel/logout") .build(); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("https://localhost:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("https://localhost:8090/logout"); } @Test public void computeLogoutEndpointWhenUsingBaseUrlTemplateThenServerName() { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(this.sessionRegistry); logoutHandler.setLogoutUri("{baseUrl}/logout"); MockServerHttpRequest request = MockServerHttpRequest .get("http://host.docker.internal:8090/back-channel/logout") .build(); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://host.docker.internal:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://host.docker.internal:8090/logout"); } // gh-14609 @Test public void computeLogoutEndpointWhenLogoutUriThenUses() { - OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(); + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(this.sessionRegistry); logoutHandler.setLogoutUri("http://localhost:8090/logout"); MockServerHttpRequest request = MockServerHttpRequest.get("https://server-one.com/back-channel/logout").build(); - String endpoint = logoutHandler.computeLogoutEndpoint(request); - assertThat(endpoint).isEqualTo("http://localhost:8090/logout"); + String endpoint = logoutHandler.computeLogoutEndpoint(request, this.token); + assertThat(endpoint).startsWith("http://localhost:8090/logout"); } } diff --git a/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java index fed59255f09..7e24cd3816f 100644 --- a/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java +++ b/config/src/test/java/org/springframework/security/config/web/server/OidcLogoutSpecTests.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; @@ -96,6 +97,7 @@ import org.springframework.web.server.WebSession; import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -268,6 +270,52 @@ void logoutWhenRemoteLogoutUriThenUses() { this.test.get().uri("/token/logout").cookie("SESSION", one).exchange().expectStatus().isOk(); } + @Test + void logoutWhenSelfRemoteLogoutUriThenUses() { + this.spring.register(WebServerConfig.class, OidcProviderConfig.class, SelfLogoutUriConfig.class).autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + String sessionId = login(); + String logoutToken = this.test.get() + .uri("/token/logout") + .cookie("SESSION", sessionId) + .exchange() + .expectStatus() + .isOk() + .returnResult(String.class) + .getResponseBody() + .blockFirst(); + this.test.post() + .uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .body(BodyInserters.fromFormData("logout_token", logoutToken)) + .exchange() + .expectStatus() + .isOk(); + this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus().isUnauthorized(); + } + + @Test + void logoutWhenDifferentCookieNameThenUses() { + this.spring.register(OidcProviderConfig.class, CookieConfig.class).autowire(); + String registrationId = this.clientRegistration.getRegistrationId(); + String sessionId = login(); + String logoutToken = this.test.get() + .uri("/token/logout") + .cookie("SESSION", sessionId) + .exchange() + .expectStatus() + .isOk() + .returnResult(String.class) + .getResponseBody() + .blockFirst(); + this.test.post() + .uri(this.web.url("/logout/connect/back-channel/" + registrationId).toString()) + .body(BodyInserters.fromFormData("logout_token", logoutToken)) + .exchange() + .expectStatus() + .isOk(); + this.test.get().uri("/token/logout").cookie("SESSION", sessionId).exchange().expectStatus().isUnauthorized(); + } + @Test void logoutWhenRemoteLogoutFailsThenReportsPartialLogout() { this.spring.register(WebServerConfig.class, OidcProviderConfig.class, WithBrokenLogoutConfig.class).autowire(); @@ -444,6 +492,83 @@ SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception { } + @Configuration + @EnableWebFluxSecurity + @Import(RegistrationConfig.class) + static class SelfLogoutUriConfig { + + @Bean + @Order(1) + SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeExchange((authorize) -> authorize.anyExchange().authenticated()) + .oauth2Login(Customizer.withDefaults()) + .oidcLogout((oidc) -> oidc + .backChannel(Customizer.withDefaults()) + ); + // @formatter:on + + return http.build(); + } + + } + + @Configuration + @EnableWebFluxSecurity + @Import(RegistrationConfig.class) + static class CookieConfig { + + private final MockWebServer server = new MockWebServer(); + + @Bean + @Order(1) + SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeExchange((authorize) -> authorize.anyExchange().authenticated()) + .oauth2Login(Customizer.withDefaults()) + .oidcLogout((oidc) -> oidc + .backChannel(Customizer.withDefaults()) + ); + // @formatter:on + + return http.build(); + } + + @Bean + ReactiveOidcSessionRegistry oidcSessionRegistry() { + return new InMemoryReactiveOidcSessionRegistry(); + } + + @Bean + OidcBackChannelServerLogoutHandler oidcLogoutHandler(ReactiveOidcSessionRegistry sessionRegistry) { + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(sessionRegistry); + logoutHandler.setSessionCookieName("JSESSIONID"); + return logoutHandler; + } + + @Bean + MockWebServer web(ObjectProvider web) { + WebTestClientDispatcher dispatcher = new WebTestClientDispatcher(web); + dispatcher.setAssertion((rr) -> { + String cookie = rr.getHeaders().get("Cookie"); + if (cookie == null) { + return; + } + assertThat(cookie).contains("JSESSIONID"); + }); + this.server.setDispatcher(dispatcher); + return this.server; + } + + @PreDestroy + void shutdown() throws IOException { + this.server.shutdown(); + } + + } + @Configuration @EnableWebFluxSecurity @Import(RegistrationConfig.class) @@ -457,7 +582,7 @@ SecurityWebFilterChain filters(ServerHttpSecurity http) throws Exception { // @formatter:off http .authorizeExchange((authorize) -> authorize.anyExchange().authenticated()) - .oauth2Login((oauth2) -> oauth2.oidcSessionRegistry(this.sessionRegistry)) + .oauth2Login(Customizer.withDefaults()) .oidcLogout((oidc) -> oidc.backChannel(Customizer.withDefaults())); // @formatter:on @@ -652,12 +777,15 @@ private static class WebTestClientDispatcher extends Dispatcher { private WebTestClient web; + private Consumer assertion = (rr) -> { }; + WebTestClientDispatcher(ObjectProvider web) { this.webProvider = web; } @Override public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + this.assertion.accept(request); this.web = this.webProvider.getObject(); String method = request.getMethod(); String path = request.getPath(); @@ -700,6 +828,10 @@ public MockResponse dispatch(RecordedRequest request) throws InterruptedExceptio } } + void setAssertion(Consumer assertion) { + this.assertion = assertion; + } + private String session(RecordedRequest request) { String cookieHeaderValue = request.getHeader("Cookie"); if (cookieHeaderValue == null) { @@ -711,6 +843,9 @@ private String session(RecordedRequest request) { if (SESSION_COOKIE_NAME.equals(parts[0])) { return parts[1]; } + if ("JSESSIONID".equals(parts[0])) { + return parts[1]; + } } return null; } diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt index 468be4251bb..2518991b8d8 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OidcLogoutDslTests.kt @@ -23,13 +23,19 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcBackChannelLogoutHandler +import org.springframework.security.config.annotation.web.oauth2.login.OidcBackChannelLogoutDsl import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.oauth2.client.oidc.session.InMemoryOidcSessionRegistry +import org.springframework.security.oauth2.client.oidc.session.OidcSessionRegistry import org.springframework.security.oauth2.client.registration.ClientRegistration import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository import org.springframework.security.oauth2.client.registration.TestClientRegistrations import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.logout.LogoutHandler +import org.springframework.test.util.ReflectionTestUtils import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post @@ -53,12 +59,23 @@ class OidcLogoutDslTests { this.mockMvc.post("/logout/connect/back-channel/" + clientRegistration.registrationId) { param("logout_token", "token") }.andExpect { status { isBadRequest() } } + val chain: SecurityFilterChain = this.spring.context.getBean(SecurityFilterChain::class.java) + for (filter in chain.filters) { + if (filter.javaClass.simpleName.equals("OidcBackChannelLogoutFilter")) { + val logoutHandler = ReflectionTestUtils.getField(filter, "logoutHandler") as LogoutHandler + val backChannelLogoutHandler = ReflectionTestUtils.getField(logoutHandler, "left") as LogoutHandler + var cookieName = ReflectionTestUtils.getField(backChannelLogoutHandler, "sessionCookieName") as String + assert(cookieName.equals("SESSION")) + } + } } @Configuration @EnableWebSecurity open class ClientRepositoryConfig { + private val sessionRegistry = InMemoryOidcSessionRegistry() + @Bean open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { http { @@ -73,6 +90,13 @@ class OidcLogoutDslTests { return http.build() } + @Bean + open fun oidcLogoutHandler(): OidcBackChannelLogoutHandler { + val logoutHandler = OidcBackChannelLogoutHandler(this.sessionRegistry) + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; + } + @Bean open fun clientRegistration(): ClientRegistration { return TestClientRegistrations.clientRegistration().build() diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt index a4b62b2dc0f..4bc0ca993d6 100644 --- a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOidcLogoutDslTests.kt @@ -25,14 +25,18 @@ import org.springframework.context.annotation.Configuration import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.test.SpringTestContext import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.oauth2.client.oidc.server.session.InMemoryReactiveOidcSessionRegistry 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.client.registration.TestClientRegistrations +import org.springframework.security.web.authentication.logout.LogoutHandler import org.springframework.security.web.server.SecurityWebFilterChain +import org.springframework.test.util.ReflectionTestUtils import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.web.reactive.config.EnableWebFlux import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.server.WebFilter /** * Tests for [ServerOidcLogoutDsl] @@ -63,6 +67,15 @@ class ServerOidcLogoutDslTests { .body(BodyInserters.fromFormData("logout_token", "token")) .exchange() .expectStatus().isBadRequest + val chain: SecurityWebFilterChain = this.spring.context.getBean(SecurityWebFilterChain::class.java) + chain.webFilters.doOnNext({ filter: WebFilter -> + if (filter.javaClass.simpleName.equals("OidcBackChannelLogoutWebFilter")) { + val logoutHandler = ReflectionTestUtils.getField(filter, "logoutHandler") as LogoutHandler + val backChannelLogoutHandler = ReflectionTestUtils.getField(logoutHandler, "left") as LogoutHandler + var cookieName = ReflectionTestUtils.getField(backChannelLogoutHandler, "sessionCookieName") as String + assert(cookieName.equals("SESSION")) + } + }) } @Configuration @@ -70,6 +83,8 @@ class ServerOidcLogoutDslTests { @EnableWebFluxSecurity open class ClientRepositoryConfig { + private val sessionRegistry = InMemoryReactiveOidcSessionRegistry() + @Bean open fun securityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { return http { @@ -83,6 +98,13 @@ class ServerOidcLogoutDslTests { } } + @Bean + open fun oidcLogoutHandler(): OidcBackChannelServerLogoutHandler { + val logoutHandler = OidcBackChannelServerLogoutHandler(this.sessionRegistry) + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; + } + @Bean open fun clientRegistration(): ClientRegistration { return TestClientRegistrations.clientRegistration().build() diff --git a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisorProxyFactory.java b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisorProxyFactory.java index 88db8d6bc7e..5cf36b5fa21 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisorProxyFactory.java +++ b/core/src/main/java/org/springframework/security/authorization/method/AuthorizationAdvisorProxyFactory.java @@ -169,6 +169,9 @@ public Object proxy(Object target) { if (target == null) { return null; } + if (target instanceof AuthorizationProxy proxied) { + return proxied; + } Object proxied = this.visitor.visit(this, target); if (proxied != null) { return proxied; @@ -365,6 +368,9 @@ private static final class ClassVisitor implements TargetVisitor { @Override public Object visit(AuthorizationAdvisorProxyFactory proxyFactory, Object object) { if (object instanceof Class targetClass) { + if (AuthorizationProxy.class.isAssignableFrom(targetClass)) { + return targetClass; + } ProxyFactory factory = new ProxyFactory(); factory.setTargetClass(targetClass); factory.setInterfaces(ClassUtils.getAllInterfacesForClass(targetClass)); diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java index 475a7a2604e..2b09d0fd617 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PostAuthorizeReactiveAuthorizationManager.java @@ -27,6 +27,7 @@ import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults; import org.springframework.util.Assert; /** @@ -63,6 +64,18 @@ public void setTemplateDefaults(PrePostTemplateDefaults defaults) { this.registry.setTemplateDefaults(defaults); } + /** + * Configure pre/post-authorization template resolution + *

+ * By default, this value is null, which indicates that templates should + * not be resolved. + * @param defaults - whether to resolve pre/post-authorization templates parameters + * @since 6.4 + */ + public void setTemplateDefaults(AnnotationTemplateExpressionDefaults defaults) { + this.registry.setTemplateDefaults(defaults); + } + public void setApplicationContext(ApplicationContext context) { this.registry.setApplicationContext(context); } diff --git a/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationReactiveMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationReactiveMethodInterceptor.java index 072bb5b75d5..78079be150a 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationReactiveMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PostFilterAuthorizationReactiveMethodInterceptor.java @@ -33,6 +33,7 @@ import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations; import org.springframework.security.access.prepost.PostFilter; +import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults; import org.springframework.util.Assert; /** @@ -78,6 +79,18 @@ public void setTemplateDefaults(PrePostTemplateDefaults defaults) { this.registry.setTemplateDefaults(defaults); } + /** + * Configure pre/post-authorization template resolution + *

+ * By default, this value is null, which indicates that templates should + * not be resolved. + * @param defaults - whether to resolve pre/post-authorization templates parameters + * @since 6.4 + */ + public void setTemplateDefaults(AnnotationTemplateExpressionDefaults defaults) { + this.registry.setTemplateDefaults(defaults); + } + /** * Filters the returned object from the {@link MethodInvocation} by evaluating an * expression from the {@link PostFilter} annotation. diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java index cc768d861dd..86829e60208 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PreAuthorizeReactiveAuthorizationManager.java @@ -27,6 +27,7 @@ import org.springframework.security.authorization.AuthorizationResult; import org.springframework.security.authorization.ReactiveAuthorizationManager; import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults; import org.springframework.util.Assert; /** @@ -63,6 +64,18 @@ public void setTemplateDefaults(PrePostTemplateDefaults defaults) { this.registry.setTemplateDefaults(defaults); } + /** + * Configure pre/post-authorization template resolution + *

+ * By default, this value is null, which indicates that templates should + * not be resolved. + * @param defaults - whether to resolve pre/post-authorization templates parameters + * @since 6.4 + */ + public void setTemplateDefaults(AnnotationTemplateExpressionDefaults defaults) { + this.registry.setTemplateDefaults(defaults); + } + public void setApplicationContext(ApplicationContext context) { this.registry.setApplicationContext(context); } diff --git a/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java b/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java index 19775eb126e..2b9f9a48948 100644 --- a/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java +++ b/core/src/main/java/org/springframework/security/authorization/method/PreFilterAuthorizationReactiveMethodInterceptor.java @@ -36,6 +36,7 @@ import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler; import org.springframework.security.access.expression.method.MethodSecurityExpressionOperations; import org.springframework.security.access.prepost.PreFilter; +import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults; import org.springframework.security.core.parameters.DefaultSecurityParameterNameDiscoverer; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -81,6 +82,18 @@ public void setTemplateDefaults(PrePostTemplateDefaults defaults) { this.registry.setTemplateDefaults(defaults); } + /** + * Configure pre/post-authorization template resolution + *

+ * By default, this value is null, which indicates that templates should + * not be resolved. + * @param defaults - whether to resolve pre/post-authorization templates parameters + * @since 6.4 + */ + public void setTemplateDefaults(AnnotationTemplateExpressionDefaults defaults) { + this.registry.setTemplateDefaults(defaults); + } + /** * Sets the {@link ParameterNameDiscoverer}. * @param parameterNameDiscoverer the {@link ParameterNameDiscoverer} to use diff --git a/core/src/main/java/org/springframework/security/core/annotation/SecurityAnnotationScanners.java b/core/src/main/java/org/springframework/security/core/annotation/SecurityAnnotationScanners.java index aa031d1347f..1efaf25174e 100644 --- a/core/src/main/java/org/springframework/security/core/annotation/SecurityAnnotationScanners.java +++ b/core/src/main/java/org/springframework/security/core/annotation/SecurityAnnotationScanners.java @@ -19,7 +19,9 @@ import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Factory for creating {@link SecurityAnnotationScanner} instances. @@ -29,6 +31,12 @@ */ public final class SecurityAnnotationScanners { + private static final Map, SecurityAnnotationScanner> uniqueScanners = new HashMap<>(); + + private static final Map, SecurityAnnotationScanner> uniqueTemplateScanners = new HashMap<>(); + + private static final Map>, SecurityAnnotationScanner> uniqueTypesScanners = new HashMap<>(); + private SecurityAnnotationScanners() { } @@ -40,7 +48,8 @@ private SecurityAnnotationScanners() { * @return the default {@link SecurityAnnotationScanner} */ public static SecurityAnnotationScanner requireUnique(Class type) { - return new UniqueSecurityAnnotationScanner<>(type); + return (SecurityAnnotationScanner) uniqueScanners.computeIfAbsent(type, + (t) -> new UniqueSecurityAnnotationScanner<>(type)); } /** @@ -60,9 +69,10 @@ public static SecurityAnnotationScanner requireUnique( public static SecurityAnnotationScanner requireUnique(Class type, AnnotationTemplateExpressionDefaults templateDefaults) { if (templateDefaults == null) { - return new UniqueSecurityAnnotationScanner<>(type); + return requireUnique(type); } - return new ExpressionTemplateSecurityAnnotationScanner<>(type, templateDefaults); + return (SecurityAnnotationScanner) uniqueTemplateScanners.computeIfAbsent(type, + (t) -> new ExpressionTemplateSecurityAnnotationScanner<>(t, templateDefaults)); } /** @@ -75,7 +85,8 @@ public static SecurityAnnotationScanner requireUnique( public static SecurityAnnotationScanner requireUnique(List> types) { List> casted = new ArrayList<>(); types.forEach((type) -> casted.add((Class) type)); - return new UniqueSecurityAnnotationScanner<>(casted); + return (SecurityAnnotationScanner) uniqueTypesScanners.computeIfAbsent(types, + (t) -> new UniqueSecurityAnnotationScanner<>(casted)); } } diff --git a/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java b/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java index e8579c28e13..248ad4611ee 100644 --- a/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java +++ b/core/src/main/java/org/springframework/security/core/annotation/UniqueSecurityAnnotationScanner.java @@ -22,10 +22,13 @@ import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import org.springframework.core.MethodClassKey; import org.springframework.core.annotation.AnnotationConfigurationException; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; @@ -86,6 +89,10 @@ final class UniqueSecurityAnnotationScanner extends Abstra private final List> types; + private final Map> uniqueParameterAnnotationCache = new HashMap<>(); + + private final Map> uniqueMethodAnnotationCache = new HashMap<>(); + UniqueSecurityAnnotationScanner(Class type) { Assert.notNull(type, "type cannot be null"); this.types = List.of(type); @@ -99,12 +106,16 @@ final class UniqueSecurityAnnotationScanner extends Abstra @Override MergedAnnotation merge(AnnotatedElement element, Class targetClass) { if (element instanceof Parameter parameter) { - List> annotations = findDirectAnnotations(parameter); - return requireUnique(parameter, annotations); + return this.uniqueParameterAnnotationCache.computeIfAbsent(parameter, (p) -> { + List> annotations = findDirectAnnotations(p); + return requireUnique(p, annotations); + }); } if (element instanceof Method method) { - List> annotations = findMethodAnnotations(method, targetClass); - return requireUnique(method, annotations); + return this.uniqueMethodAnnotationCache.computeIfAbsent(new MethodClassKey(method, targetClass), (k) -> { + List> annotations = findMethodAnnotations(method, targetClass); + return requireUnique(method, annotations); + }); } throw new AnnotationConfigurationException("Unsupported element of type " + element.getClass()); } diff --git a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc index c1545aeb74f..15ce91c39c0 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/login/logout.adoc @@ -137,6 +137,11 @@ Java:: + [source,java,role="primary"] ---- +@Bean +OidcBackChannelServerLogoutHandler oidcLogoutHandler() { + return new OidcBackChannelServerLogoutHandler(); +} + @Bean public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception { http @@ -155,6 +160,11 @@ Kotlin:: + [source,kotlin,role="secondary"] ---- +@Bean +fun oidcLogoutHandler(): OidcBackChannelLogoutHandler { + return OidcBackChannelLogoutHandler() +} + @Bean open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain { http { @@ -197,6 +207,80 @@ The overall flow for a Back-Channel logout is like this: Remember that Spring Security's OIDC support is multi-tenant. This means that it will only terminate sessions whose Client matches the `aud` claim in the Logout Token. +=== Customizing the Session Logout Endpoint + +With `OidcBackChannelServerLogoutHandler` published, the session logout endpoint is `+{baseUrl}+/logout/connect/back-channel/+{registrationId}+`. + +If `OidcBackChannelServerLogoutHandler` is not wired, then the URL is `+{baseUrl}+/logout/connect/back-channel/+{registrationId}+`, which is not recommended since it requires passing a CSRF token, which can be challenging depending on the kind of repository your application uses. + +In the event that you need to customize the endpoint, you can provide the URL as follows: + + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +http + // ... + .oidcLogout((oidc) -> oidc + .backChannel((backChannel) -> backChannel + .logoutUri("http://localhost:9000/logout/connect/back-channel/+{registrationId}+") + ) + ); +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +http { + oidcLogout { + backChannel { + logoutUri = "http://localhost:9000/logout/connect/back-channel/+{registrationId}+" + } + } +} +---- +====== + +=== Customizing the Session Logout Cookie Name + +By default, the session logout endpoint uses the `JSESSIONID` cookie to correlate the session to the corresponding `OidcSessionInformation`. + +However, the default cookie name in Spring Session is `SESSION`. + +You can configure Spring Session's cookie name in the DSL like so: + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +@Bean +OidcBackChannelServerLogoutHandler oidcLogoutHandler(ReactiveOidcSessionRegistry sessionRegistry) { + OidcBackChannelServerLogoutHandler logoutHandler = new OidcBackChannelServerLogoutHandler(sessionRegistry); + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; +} +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +@Bean +open fun oidcLogoutHandler(val sessionRegistry: ReactiveOidcSessionRegistry): OidcBackChannelServerLogoutHandler { + val logoutHandler = OidcBackChannelServerLogoutHandler(sessionRegistry) + logoutHandler.setSessionCookieName("SESSION") + return logoutHandler +} +---- +====== + +[[oidc-backchannel-logout-session-registry]] === Customizing the OIDC Provider Session Registry By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session. diff --git a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc index 32577615ed2..9edd4492bdb 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/login/logout.adoc @@ -136,6 +136,11 @@ Java:: + [source,java,role="primary"] ---- +@Bean +OidcBackChannelLogoutHandler oidcLogoutHandler() { + return new OidcBackChannelLogoutHandler(); +} + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -154,6 +159,11 @@ Kotlin:: + [source,kotlin,role="secondary"] ---- +@Bean +fun oidcLogoutHandler(): OidcBackChannelLogoutHandler { + return OidcBackChannelLogoutHandler() +} + @Bean open fun filterChain(http: HttpSecurity): SecurityFilterChain { http { @@ -223,6 +233,87 @@ The overall flow for a Back-Channel logout is like this: Remember that Spring Security's OIDC support is multi-tenant. This means that it will only terminate sessions whose Client matches the `aud` claim in the Logout Token. +One notable part of this architecture's implementation is that it propagates the incoming back-channel request internally for each corresponding session. +Initially, this may seem unnecessary. +However, recall that the Servlet API does not give direct access to the `HttpSession` store. +By making an internal logout call, the corresponding session can now be validated. + +Additionally, forging a logout call internally allows for each set of ``LogoutHandler``s to be run against that session and corresponding `SecurityContext`. + +=== Customizing the Session Logout Endpoint + +With `OidcBackChannelLogoutHandler` published, the session logout endpoint is `+{baseUrl}+/logout/connect/back-channel/+{registrationId}+`. + +If `OidcBackChannelLogoutHandler` is not wired, then the URL is `+{baseUrl}+/logout/connect/back-channel/+{registrationId}+`, which is not recommended since it requires passing a CSRF token, which can be challenging depending on the kind of repository your application uses. + +In the event that you need to customize the endpoint, you can provide the URL as follows: + + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +http + // ... + .oidcLogout((oidc) -> oidc + .backChannel((backChannel) -> backChannel + .logoutUri("http://localhost:9000/logout/connect/back-channel/+{registrationId}+") + ) + ); +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +http { + oidcLogout { + backChannel { + logoutUri = "http://localhost:9000/logout/connect/back-channel/+{registrationId}+" + } + } +} +---- +====== + +=== Customizing the Session Logout Cookie Name + +By default, the session logout endpoint uses the `JSESSIONID` cookie to correlate the session to the corresponding `OidcSessionInformation`. + +However, the default cookie name in Spring Session is `SESSION`. + +You can configure Spring Session's cookie name in the DSL like so: + +[tabs] +====== +Java:: ++ +[source=java,role="primary"] +---- +@Bean +OidcBackChannelLogoutHandler oidcLogoutHandler(OidcSessionRegistry sessionRegistry) { + OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(oidcSessionRegistry); + logoutHandler.setSessionCookieName("SESSION"); + return logoutHandler; +} +---- + +Kotlin:: ++ +[source=kotlin,role="secondary"] +---- +@Bean +open fun oidcLogoutHandler(val sessionRegistry: OidcSessionRegistry): OidcBackChannelLogoutHandler { + val logoutHandler = OidcBackChannelLogoutHandler(sessionRegistry) + logoutHandler.setSessionCookieName("SESSION") + return logoutHandler +} +---- +====== + +[[oidc-backchannel-logout-session-registry]] === Customizing the OIDC Provider Session Registry By default, Spring Security stores in-memory all links between the OIDC Provider session and the Client session.