diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java index 84665715fe..9fa3ad9c47 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2013 the original author or authors. + * Copyright 2002-2018 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. @@ -23,6 +23,7 @@ import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandlerImpl; import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler; import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; @@ -70,6 +71,8 @@ public final class ExceptionHandlingConfigurer> private LinkedHashMap defaultEntryPointMappings = new LinkedHashMap<>(); + private LinkedHashMap defaultDeniedHandlerMappings = new LinkedHashMap<>(); + /** * Creates a new instance * @see HttpSecurity#exceptionHandling() @@ -104,6 +107,26 @@ public final class ExceptionHandlingConfigurer> return this; } + /** + * Sets a default {@link AccessDeniedHandler} to be used which prefers being + * invoked for the provided {@link RequestMatcher}. If only a single default + * {@link AccessDeniedHandler} is specified, it will be what is used for the + * default {@link AccessDeniedHandler}. If multiple default + * {@link AccessDeniedHandler} instances are configured, then a + * {@link RequestMatcherDelegatingAccessDeniedHandler} will be used. + * + * @param deniedHandler the {@link AccessDeniedHandler} to use + * @param preferredMatcher the {@link RequestMatcher} for this default + * {@link AccessDeniedHandler} + * @return the {@link ExceptionHandlingConfigurer} for further customizations + * @since 5.1 + */ + public ExceptionHandlingConfigurer defaultAccessDeniedHandlerFor( + AccessDeniedHandler deniedHandler, RequestMatcher preferredMatcher) { + this.defaultDeniedHandlerMappings.put(preferredMatcher, deniedHandler); + return this; + } + /** * Sets the {@link AuthenticationEntryPoint} to be used. * @@ -169,13 +192,27 @@ public final class ExceptionHandlingConfigurer> AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(http); ExceptionTranslationFilter exceptionTranslationFilter = new ExceptionTranslationFilter( entryPoint, getRequestCache(http)); - if (accessDeniedHandler != null) { - exceptionTranslationFilter.setAccessDeniedHandler(accessDeniedHandler); - } + AccessDeniedHandler deniedHandler = getAccessDeniedHandler(http); + exceptionTranslationFilter.setAccessDeniedHandler(deniedHandler); exceptionTranslationFilter = postProcess(exceptionTranslationFilter); http.addFilter(exceptionTranslationFilter); } + /** + * Gets the {@link AccessDeniedHandler} according to the rules specified by + * {@link #accessDeniedHandler(AccessDeniedHandler)} + * @param http the {@link HttpSecurity} used to look up shared + * {@link AccessDeniedHandler} + * @return the {@link AccessDeniedHandler} to use + */ + AccessDeniedHandler getAccessDeniedHandler(H http) { + AccessDeniedHandler deniedHandler = this.accessDeniedHandler; + if (deniedHandler == null) { + deniedHandler = createDefaultDeniedHandler(http); + } + return deniedHandler; + } + /** * Gets the {@link AuthenticationEntryPoint} according to the rules specified by * {@link #authenticationEntryPoint(AuthenticationEntryPoint)} @@ -191,16 +228,28 @@ public final class ExceptionHandlingConfigurer> return entryPoint; } + private AccessDeniedHandler createDefaultDeniedHandler(H http) { + if (this.defaultDeniedHandlerMappings.isEmpty()) { + return new AccessDeniedHandlerImpl(); + } + if (this.defaultDeniedHandlerMappings.size() == 1) { + return this.defaultDeniedHandlerMappings.values().iterator().next(); + } + return new RequestMatcherDelegatingAccessDeniedHandler( + this.defaultDeniedHandlerMappings, + new AccessDeniedHandlerImpl()); + } + private AuthenticationEntryPoint createDefaultEntryPoint(H http) { - if (defaultEntryPointMappings.isEmpty()) { + if (this.defaultEntryPointMappings.isEmpty()) { return new Http403ForbiddenEntryPoint(); } - if (defaultEntryPointMappings.size() == 1) { - return defaultEntryPointMappings.values().iterator().next(); + if (this.defaultEntryPointMappings.size() == 1) { + return this.defaultEntryPointMappings.values().iterator().next(); } DelegatingAuthenticationEntryPoint entryPoint = new DelegatingAuthenticationEntryPoint( - defaultEntryPointMappings); - entryPoint.setDefaultEntryPoint(defaultEntryPointMappings.values().iterator() + this.defaultEntryPointMappings); + entryPoint.setDefaultEntryPoint(this.defaultEntryPointMappings.values().iterator() .next()); return entryPoint; } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurerAccessDeniedHandlerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurerAccessDeniedHandlerTests.java new file mode 100644 index 0000000000..91c1076d13 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurerAccessDeniedHandlerTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.web.configurers; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +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.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.test.SpringTestRule; +import org.springframework.security.test.context.annotation.SecurityTestExecutionListeners; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.AccessDeniedHandlerImpl; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Josh Cummings + */ +@RunWith(SpringJUnit4ClassRunner.class) +@SecurityTestExecutionListeners +public class ExceptionHandlingConfigurerAccessDeniedHandlerTests { + @Autowired + MockMvc mvc; + + @Rule + public final SpringTestRule spring = new SpringTestRule(); + + @Test + @WithMockUser(roles = "ANYTHING") + public void getWhenAccessDeniedOverriddenThenCustomizesResponseByRequest() + throws Exception { + this.spring.register(RequestMatcherBasedAccessDeniedHandlerConfig.class).autowire(); + + this.mvc.perform(get("/hello")) + .andExpect(status().isIAmATeapot()); + + this.mvc.perform(get("/goodbye")) + .andExpect(status().isForbidden()); + } + + @EnableWebSecurity + static class RequestMatcherBasedAccessDeniedHandlerConfig extends WebSecurityConfigurerAdapter { + AccessDeniedHandler teapotDeniedHandler = + (request, response, exception) -> + response.setStatus(HttpStatus.I_AM_A_TEAPOT.value()); + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .anyRequest().denyAll() + .and() + .exceptionHandling() + .defaultAccessDeniedHandlerFor( + this.teapotDeniedHandler, + new AntPathRequestMatcher("/hello/**")) + .defaultAccessDeniedHandlerFor( + new AccessDeniedHandlerImpl(), + AnyRequestMatcher.INSTANCE); + // @formatter:on + } + } + + @Test + @WithMockUser(roles = "ANYTHING") + public void getWhenAccessDeniedOverriddenByOnlyOneHandlerThenAllRequestsUseThatHandler() + throws Exception { + this.spring.register(SingleRequestMatcherAccessDeniedHandlerConfig.class).autowire(); + + this.mvc.perform(get("/hello")) + .andExpect(status().isIAmATeapot()); + + this.mvc.perform(get("/goodbye")) + .andExpect(status().isIAmATeapot()); + } + + @EnableWebSecurity + static class SingleRequestMatcherAccessDeniedHandlerConfig extends WebSecurityConfigurerAdapter { + AccessDeniedHandler teapotDeniedHandler = + (request, response, exception) -> + response.setStatus(HttpStatus.I_AM_A_TEAPOT.value()); + + @Override + protected void configure(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeRequests() + .anyRequest().denyAll() + .and() + .exceptionHandling() + .defaultAccessDeniedHandlerFor( + this.teapotDeniedHandler, + new AntPathRequestMatcher("/hello/**")); + // @formatter:on + } + } +} diff --git a/web/src/main/java/org/springframework/security/web/access/RequestMatcherDelegatingAccessDeniedHandler.java b/web/src/main/java/org/springframework/security/web/access/RequestMatcherDelegatingAccessDeniedHandler.java new file mode 100644 index 0000000000..22e8eb10cd --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/access/RequestMatcherDelegatingAccessDeniedHandler.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.web.access; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map.Entry; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * An {@link AccessDeniedHandler} that delegates to other {@link AccessDeniedHandler} + * instances based upon the type of {@link HttpServletRequest} passed into + * {@link #handle(HttpServletRequest, HttpServletResponse, AccessDeniedException)}. + * + * @author Josh Cummings + * @since 5.1 + * + */ +public final class RequestMatcherDelegatingAccessDeniedHandler implements AccessDeniedHandler { + private final LinkedHashMap handlers; + + private final AccessDeniedHandler defaultHandler; + + /** + * Creates a new instance + * + * @param handlers a map of {@link RequestMatcher}s to + * {@link AccessDeniedHandler}s that should be used. Each is considered in the order + * they are specified and only the first {@link AccessDeniedHandler} is used. + * @param defaultHandler the default {@link AccessDeniedHandler} that should be used + * if none of the matchers match. + */ + public RequestMatcherDelegatingAccessDeniedHandler( + LinkedHashMap handlers, + AccessDeniedHandler defaultHandler) { + Assert.notEmpty(handlers, "handlers cannot be null or empty"); + Assert.notNull(defaultHandler, "defaultHandler cannot be null"); + this.handlers = new LinkedHashMap<>(handlers); + this.defaultHandler = defaultHandler; + } + + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, + ServletException { + for (Entry entry : this.handlers + .entrySet()) { + RequestMatcher matcher = entry.getKey(); + if (matcher.matches(request)) { + AccessDeniedHandler handler = entry.getValue(); + handler.handle(request, response, accessDeniedException); + return; + } + } + defaultHandler.handle(request, response, accessDeniedException); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/access/RequestMatcherDelegatingAccessDeniedHandlerTests.java b/web/src/test/java/org/springframework/security/web/access/RequestMatcherDelegatingAccessDeniedHandlerTests.java new file mode 100644 index 0000000000..87e853ee73 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/access/RequestMatcherDelegatingAccessDeniedHandlerTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.web.access; + +import java.util.LinkedHashMap; +import javax.servlet.http.HttpServletRequest; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Josh Cummings + */ +public class RequestMatcherDelegatingAccessDeniedHandlerTests { + private RequestMatcherDelegatingAccessDeniedHandler delegator; + private LinkedHashMap deniedHandlers; + private AccessDeniedHandler accessDeniedHandler; + private HttpServletRequest request; + + @Before + public void setup() { + this.accessDeniedHandler = mock(AccessDeniedHandler.class); + this.deniedHandlers = new LinkedHashMap<>(); + this.request = new MockHttpServletRequest(); + } + + @Test + public void handleWhenNothingMatchesThenOnlyDefaultHandlerInvoked() throws Exception { + AccessDeniedHandler handler = mock(AccessDeniedHandler.class); + RequestMatcher matcher = mock(RequestMatcher.class); + when(matcher.matches(this.request)).thenReturn(false); + this.deniedHandlers.put(matcher, handler); + this.delegator = new RequestMatcherDelegatingAccessDeniedHandler(this.deniedHandlers, this.accessDeniedHandler); + + this.delegator.handle(this.request, null, null); + + verify(this.accessDeniedHandler).handle(this.request, null, null); + verify(handler, never()).handle(this.request, null, null); + } + + @Test + public void handleWhenFirstMatchesThenOnlyFirstInvoked() throws Exception { + AccessDeniedHandler firstHandler = mock(AccessDeniedHandler.class); + RequestMatcher firstMatcher = mock(RequestMatcher.class); + AccessDeniedHandler secondHandler = mock(AccessDeniedHandler.class); + RequestMatcher secondMatcher = mock(RequestMatcher.class); + when(firstMatcher.matches(this.request)).thenReturn(true); + this.deniedHandlers.put(firstMatcher, firstHandler); + this.deniedHandlers.put(secondMatcher, secondHandler); + this.delegator = new RequestMatcherDelegatingAccessDeniedHandler(this.deniedHandlers, this.accessDeniedHandler); + + this.delegator.handle(this.request, null, null); + + verify(firstHandler).handle(this.request, null, null); + verify(secondHandler, never()).handle(this.request, null, null); + verify(this.accessDeniedHandler, never()).handle(this.request, null, null); + verify(secondMatcher, never()).matches(this.request); + } + + @Test + public void handleWhenSecondMatchesThenOnlySecondInvoked() throws Exception { + AccessDeniedHandler firstHandler = mock(AccessDeniedHandler.class); + RequestMatcher firstMatcher = mock(RequestMatcher.class); + AccessDeniedHandler secondHandler = mock(AccessDeniedHandler.class); + RequestMatcher secondMatcher = mock(RequestMatcher.class); + when(firstMatcher.matches(this.request)).thenReturn(false); + when(secondMatcher.matches(this.request)).thenReturn(true); + this.deniedHandlers.put(firstMatcher, firstHandler); + this.deniedHandlers.put(secondMatcher, secondHandler); + this.delegator = new RequestMatcherDelegatingAccessDeniedHandler(this.deniedHandlers, this.accessDeniedHandler); + + this.delegator.handle(this.request, null, null); + + verify(secondHandler).handle(this.request, null, null); + verify(firstHandler, never()).handle(this.request, null, null); + verify(this.accessDeniedHandler, never()).handle(this.request, null, null); + } +}