From ca00b1415be7d6c01b7d7458ec6df2df67a34f51 Mon Sep 17 00:00:00 2001 From: Yuriy Savchenko Date: Sun, 13 Mar 2022 12:06:24 +0300 Subject: [PATCH] Add authorizeHttpRequests to Kotlin DSL Closes gh-10481 --- .../web/AbstractRequestMatcherDsl.kt | 15 +- .../web/AuthorizeHttpRequestsDsl.kt | 253 +++++++ .../config/annotation/web/HttpSecurityDsl.kt | 37 +- .../web/AuthorizeHttpRequestsDslTests.kt | 644 ++++++++++++++++++ .../annotation/web/HttpSecurityDslTests.kt | 145 +++- 5 files changed, 1063 insertions(+), 31 deletions(-) create mode 100644 config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt create mode 100644 config/src/test/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDslTests.kt diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/AbstractRequestMatcherDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/AbstractRequestMatcherDsl.kt index 0a4720341e..ba0087a3aa 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/AbstractRequestMatcherDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/AbstractRequestMatcherDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2022 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. @@ -17,6 +17,8 @@ package org.springframework.security.config.annotation.web import org.springframework.http.HttpMethod +import org.springframework.security.authorization.AuthorizationManager +import org.springframework.security.web.access.intercept.RequestAuthorizationContext import org.springframework.security.web.util.matcher.AnyRequestMatcher import org.springframework.security.web.util.matcher.RequestMatcher @@ -36,14 +38,25 @@ abstract class AbstractRequestMatcherDsl { protected data class MatcherAuthorizationRule(val matcher: RequestMatcher, override val rule: String) : AuthorizationRule(rule) + protected data class MatcherAuthorizationManagerRule(val matcher: RequestMatcher, + override val rule: AuthorizationManager) : AuthorizationManagerRule(rule) + protected data class PatternAuthorizationRule(val pattern: String, val patternType: PatternType, val servletPath: String? = null, val httpMethod: HttpMethod? = null, override val rule: String) : AuthorizationRule(rule) + protected data class PatternAuthorizationManagerRule(val pattern: String, + val patternType: PatternType, + val servletPath: String? = null, + val httpMethod: HttpMethod? = null, + override val rule: AuthorizationManager) : AuthorizationManagerRule(rule) + protected abstract class AuthorizationRule(open val rule: String) + protected abstract class AuthorizationManagerRule(open val rule: AuthorizationManager) + protected enum class PatternType { ANT, MVC } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt new file mode 100644 index 0000000000..d6fa22f31e --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDsl.kt @@ -0,0 +1,253 @@ +/* + * Copyright 2002-2022 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.web + +import org.springframework.http.HttpMethod +import org.springframework.security.authorization.AuthenticatedAuthorizationManager +import org.springframework.security.authorization.AuthorityAuthorizationManager +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.authorization.AuthorizationManager +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer +import org.springframework.security.core.Authentication +import org.springframework.security.web.access.intercept.RequestAuthorizationContext +import org.springframework.security.web.util.matcher.AnyRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher +import org.springframework.util.ClassUtils +import java.util.function.Supplier + +/** + * A Kotlin DSL to configure [HttpSecurity] request authorization using idiomatic Kotlin code. + * + * @author Yuriy Savchenko + * @since 5.7 + */ +class AuthorizeHttpRequestsDsl : AbstractRequestMatcherDsl() { + private val authorizationRules = mutableListOf() + + private val HANDLER_MAPPING_INTROSPECTOR = "org.springframework.web.servlet.handler.HandlerMappingIntrospector" + private val MVC_PRESENT = ClassUtils.isPresent( + HANDLER_MAPPING_INTROSPECTOR, + AuthorizeHttpRequestsDsl::class.java.classLoader) + private val PATTERN_TYPE = if (MVC_PRESENT) PatternType.MVC else PatternType.ANT + + /** + * Adds a request authorization rule. + * + * @param matches the [RequestMatcher] to match incoming requests against + * @param access the [AuthorizationManager] to secure the matching request + * (i.e. created via hasAuthority("ROLE_USER")) + */ + fun authorize(matches: RequestMatcher = AnyRequestMatcher.INSTANCE, + access: AuthorizationManager) { + authorizationRules.add(MatcherAuthorizationManagerRule(matches, access)) + } + + /** + * Adds a request authorization rule for an endpoint matching the provided + * pattern. + * If Spring MVC is on the classpath, it will use an MVC matcher. + * If Spring MVC is not on the classpath, it will use an ant matcher. + * The MVC will use the same rules that Spring MVC uses for matching. + * For example, often times a mapping of the path "/path" will match on + * "/path", "/path/", "/path.html", etc. + * If the current request will not be processed by Spring MVC, a reasonable default + * using the pattern as an ant pattern will be used. + * + * @param pattern the pattern to match incoming requests against. + * @param access the [AuthorizationManager] to secure the matching request + * (i.e. created via hasAuthority("ROLE_USER")) + */ + fun authorize(pattern: String, + access: AuthorizationManager) { + authorizationRules.add( + PatternAuthorizationManagerRule( + pattern = pattern, + patternType = PATTERN_TYPE, + rule = access + ) + ) + } + + /** + * Adds a request authorization rule for an endpoint matching the provided + * pattern. + * If Spring MVC is on the classpath, it will use an MVC matcher. + * If Spring MVC is not on the classpath, it will use an ant matcher. + * The MVC will use the same rules that Spring MVC uses for matching. + * For example, often times a mapping of the path "/path" will match on + * "/path", "/path/", "/path.html", etc. + * If the current request will not be processed by Spring MVC, a reasonable default + * using the pattern as an ant pattern will be used. + * + * @param method the HTTP method to match the income requests against. + * @param pattern the pattern to match incoming requests against. + * @param access the [AuthorizationManager] to secure the matching request + * (i.e. created via hasAuthority("ROLE_USER")) + */ + fun authorize(method: HttpMethod, + pattern: String, + access: AuthorizationManager) { + authorizationRules.add( + PatternAuthorizationManagerRule( + pattern = pattern, + patternType = PATTERN_TYPE, + httpMethod = method, + rule = access + ) + ) + } + + /** + * Adds a request authorization rule for an endpoint matching the provided + * pattern. + * If Spring MVC is on the classpath, it will use an MVC matcher. + * If Spring MVC is not on the classpath, it will use an ant matcher. + * The MVC will use the same rules that Spring MVC uses for matching. + * For example, often times a mapping of the path "/path" will match on + * "/path", "/path/", "/path.html", etc. + * If the current request will not be processed by Spring MVC, a reasonable default + * using the pattern as an ant pattern will be used. + * + * @param pattern the pattern to match incoming requests against. + * @param servletPath the servlet path to match incoming requests against. This + * only applies when using an MVC pattern matcher. + * @param access the [AuthorizationManager] to secure the matching request + * (i.e. created via hasAuthority("ROLE_USER")) + */ + fun authorize(pattern: String, + servletPath: String, + access: AuthorizationManager) { + authorizationRules.add( + PatternAuthorizationManagerRule( + pattern = pattern, + patternType = PATTERN_TYPE, + servletPath = servletPath, + rule = access + ) + ) + } + + /** + * Adds a request authorization rule for an endpoint matching the provided + * pattern. + * If Spring MVC is on the classpath, it will use an MVC matcher. + * If Spring MVC is not on the classpath, it will use an ant matcher. + * The MVC will use the same rules that Spring MVC uses for matching. + * For example, often times a mapping of the path "/path" will match on + * "/path", "/path/", "/path.html", etc. + * If the current request will not be processed by Spring MVC, a reasonable default + * using the pattern as an ant pattern will be used. + * + * @param method the HTTP method to match the income requests against. + * @param pattern the pattern to match incoming requests against. + * @param servletPath the servlet path to match incoming requests against. This + * only applies when using an MVC pattern matcher. + * @param access the [AuthorizationManager] to secure the matching request + * (i.e. created via hasAuthority("ROLE_USER")) + */ + fun authorize(method: HttpMethod, + pattern: String, + servletPath: String, + access: AuthorizationManager) { + authorizationRules.add( + PatternAuthorizationManagerRule( + pattern = pattern, + patternType = PATTERN_TYPE, + servletPath = servletPath, + httpMethod = method, + rule = access + ) + ) + } + + /** + * Specify that URLs require a particular authority. + * + * @param authority the authority to require (i.e. ROLE_USER, ROLE_ADMIN, etc). + * @return the [AuthorizationManager] with the provided authority + */ + fun hasAuthority(authority: String): AuthorizationManager { + return AuthorityAuthorizationManager.hasAuthority(authority) + } + + /** + * Specify that URLs require any of the provided authorities. + * + * @param authorities the authorities to require (i.e. ROLE_USER, ROLE_ADMIN, etc). + * @return the [AuthorizationManager] with the provided authorities + */ + fun hasAnyAuthority(vararg authorities: String): AuthorizationManager { + return AuthorityAuthorizationManager.hasAnyAuthority(*authorities) + } + + /** + * Specify that URLs require a particular role. + * + * @param role the role to require (i.e. USER, ADMIN, etc). + * @return the [AuthorizationManager] with the provided role + */ + fun hasRole(role: String): AuthorizationManager { + return AuthorityAuthorizationManager.hasRole(role) + } + + /** + * Specify that URLs require any of the provided roles. + * + * @param roles the roles to require (i.e. USER, ADMIN, etc). + * @return the [AuthorizationManager] with the provided roles + */ + fun hasAnyRole(vararg roles: String): AuthorizationManager { + return AuthorityAuthorizationManager.hasAnyRole(*roles) + } + + /** + * Specify that URLs are allowed by anyone. + */ + val permitAll: AuthorizationManager = + AuthorizationManager { _: Supplier, _: RequestAuthorizationContext -> AuthorizationDecision(true) } + + /** + * Specify that URLs are not allowed by anyone. + */ + val denyAll: AuthorizationManager = + AuthorizationManager { _: Supplier, _: RequestAuthorizationContext -> AuthorizationDecision(false) } + + /** + * Specify that URLs are allowed by any authenticated user. + */ + val authenticated: AuthorizationManager = + AuthenticatedAuthorizationManager.authenticated() + + internal fun get(): (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry) -> Unit { + return { requests -> + authorizationRules.forEach { rule -> + when (rule) { + is MatcherAuthorizationManagerRule -> requests.requestMatchers(rule.matcher).access(rule.rule) + is PatternAuthorizationManagerRule -> { + when (rule.patternType) { + PatternType.ANT -> requests.antMatchers(rule.httpMethod, rule.pattern).access(rule.rule) + PatternType.MVC -> requests.mvcMatchers(rule.httpMethod, rule.pattern) + .apply { if (rule.servletPath != null) servletPath(rule.servletPath) } + .access(rule.rule) + } + } + } + } + } + } +} diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt index c9ffcd836f..3f73de6d18 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt @@ -101,7 +101,10 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu fun securityMatcher(vararg pattern: String) { val mvcPresent = ClassUtils.isPresent( HANDLER_MAPPING_INTROSPECTOR, - AuthorizeRequestsDsl::class.java.classLoader) + AuthorizeRequestsDsl::class.java.classLoader) || + ClassUtils.isPresent( + HANDLER_MAPPING_INTROSPECTOR, + AuthorizeHttpRequestsDsl::class.java.classLoader) this.http.requestMatchers { if (mvcPresent) { it.mvcMatchers(*pattern) @@ -198,6 +201,38 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu this.http.authorizeRequests(authorizeRequestsCustomizer) } + /** + * Allows restricting access based upon the [HttpServletRequest] + * + * Example: + * + * ``` + * @EnableWebSecurity + * class SecurityConfig { + * + * @Bean + * fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + * http { + * authorizeHttpRequests { + * authorize("/public", permitAll) + * authorize(anyRequest, authenticated) + * } + * } + * return http.build() + * } + * } + * ``` + * + * @param authorizeHttpRequestsConfiguration custom configuration that specifies + * access for requests + * @see [AuthorizeHttpRequestsDsl] + * @since 5.7 + */ + fun authorizeHttpRequests(authorizeHttpRequestsConfiguration: AuthorizeHttpRequestsDsl.() -> Unit) { + val authorizeHttpRequestsCustomizer = AuthorizeHttpRequestsDsl().apply(authorizeHttpRequestsConfiguration).get() + this.http.authorizeHttpRequests(authorizeHttpRequestsCustomizer) + } + /** * Enables HTTP basic authentication. * diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDslTests.kt new file mode 100644 index 0000000000..7953190675 --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/AuthorizeHttpRequestsDslTests.kt @@ -0,0 +1,644 @@ +/* + * Copyright 2002-2022 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.web + +import org.assertj.core.api.Assertions.* +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.UnsatisfiedDependencyException +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.authorization.AuthorizationManager +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +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.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.access.intercept.RequestAuthorizationContext +import org.springframework.security.web.util.matcher.RegexRequestMatcher +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.put +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.servlet.config.annotation.EnableWebMvc +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import java.util.function.Supplier + +/** + * Tests for [AuthorizeHttpRequestsDsl] + * + * @author Yuriy Savchenko + */ +@ExtendWith(SpringTestContextExtension::class) +class AuthorizeHttpRequestsDslTests { + @JvmField + val spring = SpringTestContext(this) + + @Autowired + lateinit var mockMvc: MockMvc + + @Test + fun `request when secured by regex matcher then responds with forbidden`() { + this.spring.register(AuthorizeHttpRequestsByRegexConfig::class.java).autowire() + + this.mockMvc.get("/private") + .andExpect { + status { isForbidden() } + } + } + + @Test + fun `request when allowed by regex matcher then responds with ok`() { + this.spring.register(AuthorizeHttpRequestsByRegexConfig::class.java).autowire() + + this.mockMvc.get("/path") + .andExpect { + status { isOk() } + } + } + + @Test + fun `request when allowed by regex matcher with http method then responds based on method`() { + this.spring.register(AuthorizeHttpRequestsByRegexConfig::class.java).autowire() + + this.mockMvc.post("/onlyPostPermitted") { with(csrf()) } + .andExpect { + status { isOk() } + } + + this.mockMvc.get("/onlyPostPermitted") + .andExpect { + status { isForbidden() } + } + } + + @EnableWebSecurity + open class AuthorizeHttpRequestsByRegexConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(RegexRequestMatcher("/path", null), permitAll) + authorize(RegexRequestMatcher("/onlyPostPermitted", "POST"), permitAll) + authorize(RegexRequestMatcher("/onlyPostPermitted", "GET"), denyAll) + authorize(RegexRequestMatcher(".*", null), authenticated) + } + } + return http.build() + } + + @RestController + internal class PathController { + @RequestMapping("/path") + fun path() { + } + + @RequestMapping("/onlyPostPermitted") + fun onlyPostPermitted() { + } + } + } + + @Test + fun `request when secured by mvc then responds with forbidden`() { + this.spring.register(AuthorizeHttpRequestsByMvcConfig::class.java).autowire() + + this.mockMvc.get("/private") + .andExpect { + status { isForbidden() } + } + } + + @Test + fun `request when allowed by mvc then responds with OK`() { + this.spring.register(AuthorizeHttpRequestsByMvcConfig::class.java, LegacyMvcMatchingConfig::class.java).autowire() + + this.mockMvc.get("/path") + .andExpect { + status { isOk() } + } + + this.mockMvc.get("/path.html") + .andExpect { + status { isOk() } + } + + this.mockMvc.get("/path/") + .andExpect { + status { isOk() } + } + } + + @EnableWebSecurity + @EnableWebMvc + open class AuthorizeHttpRequestsByMvcConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/path", permitAll) + authorize("/**", authenticated) + } + } + return http.build() + } + + @RestController + internal class PathController { + @RequestMapping("/path") + fun path() { + } + } + } + + @Configuration + open class LegacyMvcMatchingConfig : WebMvcConfigurer { + override fun configurePathMatch(configurer: PathMatchConfigurer) { + configurer.setUseSuffixPatternMatch(true) + } + } + + @Test + fun `request when secured by mvc path variables then responds based on path variable value`() { + this.spring.register(MvcMatcherPathVariablesConfig::class.java).autowire() + + this.mockMvc.get("/user/user") + .andExpect { + status { isOk() } + } + + this.mockMvc.get("/user/deny") + .andExpect { + status { isForbidden() } + } + } + + @EnableWebSecurity + @EnableWebMvc + open class MvcMatcherPathVariablesConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + val access = AuthorizationManager { _: Supplier, context: RequestAuthorizationContext -> + AuthorizationDecision(context.variables["userName"] == "user") + } + http { + authorizeHttpRequests { + authorize("/user/{userName}", access) + } + } + return http.build() + } + + @RestController + internal class PathController { + @RequestMapping("/user/{user}") + fun path(@PathVariable user: String) { + } + } + } + + @Test + fun `request when user has allowed role then responds with OK`() { + this.spring.register(HasRoleConfig::class.java).autowire() + + this.mockMvc.get("/") { + with(httpBasic("admin", "password")) + }.andExpect { + status { isOk() } + } + } + + @Test + fun `request when user does not have allowed role then responds with forbidden`() { + this.spring.register(HasRoleConfig::class.java).autowire() + + this.mockMvc.get("/") { + with(httpBasic("user", "password")) + }.andExpect { + status { isForbidden() } + } + } + + @EnableWebSecurity + @EnableWebMvc + open class HasRoleConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/**", hasRole("ADMIN")) + } + httpBasic { } + } + return http.build() + } + + @RestController + internal class PathController { + @GetMapping("/") + fun index() { + } + } + + @Bean + open fun userDetailsService(): UserDetailsService { + val userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build() + val adminDetails = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("ADMIN") + .build() + return InMemoryUserDetailsManager(userDetails, adminDetails) + } + } + + @Test + fun `request when user has some allowed roles then responds with OK`() { + this.spring.register(HasAnyRoleConfig::class.java).autowire() + + this.mockMvc.get("/") { + with(httpBasic("user", "password")) + }.andExpect { + status { isOk() } + } + + this.mockMvc.get("/") { + with(httpBasic("admin", "password")) + }.andExpect { + status { isOk() } + } + } + + @Test + fun `request when user does not have any allowed roles then responds with forbidden`() { + this.spring.register(HasAnyRoleConfig::class.java).autowire() + + this.mockMvc.get("/") { + with(httpBasic("other", "password")) + }.andExpect { + status { isForbidden() } + } + } + + @EnableWebSecurity + @EnableWebMvc + open class HasAnyRoleConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/**", hasAnyRole("ADMIN", "USER")) + } + httpBasic { } + } + return http.build() + } + + @RestController + internal class PathController { + @GetMapping("/") + fun index() { + } + } + + @Bean + open fun userDetailsService(): UserDetailsService { + val userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build() + val admin1Details = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("ADMIN") + .build() + val admin2Details = User.withDefaultPasswordEncoder() + .username("other") + .password("password") + .roles("OTHER") + .build() + return InMemoryUserDetailsManager(userDetails, admin1Details, admin2Details) + } + } + + @Test + fun `request when user has allowed authority then responds with OK`() { + this.spring.register(HasAuthorityConfig::class.java).autowire() + + this.mockMvc.get("/") { + with(httpBasic("admin", "password")) + }.andExpect { + status { isOk() } + } + } + + @Test + fun `request when user does not have allowed authority then responds with forbidden`() { + this.spring.register(HasAuthorityConfig::class.java).autowire() + + this.mockMvc.get("/") { + with(httpBasic("user", "password")) + }.andExpect { + status { isForbidden() } + } + } + + @EnableWebSecurity + @EnableWebMvc + open class HasAuthorityConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/**", hasAuthority("ROLE_ADMIN")) + } + httpBasic { } + } + return http.build() + } + + @RestController + internal class PathController { + @GetMapping("/") + fun index() { + } + } + + @Bean + open fun userDetailsService(): UserDetailsService { + val userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build() + val adminDetails = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .roles("ADMIN") + .build() + return InMemoryUserDetailsManager(userDetails, adminDetails) + } + } + + @Test + fun `request when user has some allowed authorities then responds with OK`() { + this.spring.register(HasAnyAuthorityConfig::class.java).autowire() + + this.mockMvc.get("/") { + with(httpBasic("user", "password")) + }.andExpect { + status { isOk() } + } + + this.mockMvc.get("/") { + with(httpBasic("admin", "password")) + }.andExpect { + status { isOk() } + } + } + + @Test + fun `request when user does not have any allowed authorities then responds with forbidden`() { + this.spring.register(HasAnyAuthorityConfig::class.java).autowire() + + this.mockMvc.get("/") { + with(httpBasic("other", "password")) + }.andExpect { + status { isForbidden() } + } + } + + @EnableWebSecurity + @EnableWebMvc + open class HasAnyAuthorityConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/**", hasAnyAuthority("ROLE_ADMIN", "ROLE_USER")) + } + httpBasic { } + } + return http.build() + } + + @RestController + internal class PathController { + @GetMapping("/") + fun index() { + } + } + + @Bean + open fun userDetailsService(): UserDetailsService { + val userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("ROLE_USER") + .build() + val admin1Details = User.withDefaultPasswordEncoder() + .username("admin") + .password("password") + .authorities("ROLE_ADMIN") + .build() + val admin2Details = User.withDefaultPasswordEncoder() + .username("other") + .password("password") + .authorities("ROLE_OTHER") + .build() + return InMemoryUserDetailsManager(userDetails, admin1Details, admin2Details) + } + } + + @Test + fun `request when secured by mvc with servlet path then responds based on servlet path`() { + this.spring.register(MvcMatcherServletPathConfig::class.java).autowire() + + this.mockMvc.perform(get("/spring/path") + .with { request -> + request.servletPath = "/spring" + request + }) + .andExpect(status().isForbidden) + + this.mockMvc.perform(get("/other/path") + .with { request -> + request.servletPath = "/other" + request + }) + .andExpect(status().isOk) + } + + @EnableWebSecurity + @EnableWebMvc + open class MvcMatcherServletPathConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/path", "/spring", denyAll) + } + } + return http.build() + } + + @RestController + internal class PathController { + @RequestMapping("/path") + fun path() { + } + } + } + + @Test + fun `request when secured by mvc with http method then responds based on http method`() { + this.spring.register(AuthorizeRequestsByMvcConfigWithHttpMethod::class.java).autowire() + + this.mockMvc.get("/path") + .andExpect { + status { isOk() } + } + + this.mockMvc.put("/path") { with(csrf()) } + .andExpect { + status { isForbidden() } + } + } + + @EnableWebSecurity + @EnableWebMvc + open class AuthorizeRequestsByMvcConfigWithHttpMethod { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(HttpMethod.GET, "/path", permitAll) + authorize(HttpMethod.PUT, "/path", denyAll) + } + } + return http.build() + } + + @RestController + internal class PathController { + @RequestMapping("/path") + fun path() { + } + } + } + + @Test + fun `request when secured by mvc with servlet path and http method then responds based on path and method`() { + this.spring.register(MvcMatcherServletPathHttpMethodConfig::class.java).autowire() + + this.mockMvc.perform(get("/spring/path") + .with { request -> + request.apply { + servletPath = "/spring" + } + }) + .andExpect(status().isForbidden) + + this.mockMvc.perform(put("/spring/path") + .with { request -> + request.apply { + servletPath = "/spring" + csrf() + } + }) + .andExpect(status().isForbidden) + + this.mockMvc.perform(get("/other/path") + .with { request -> + request.apply { + servletPath = "/other" + } + }) + .andExpect(status().isOk) + } + + @EnableWebSecurity + @EnableWebMvc + open class MvcMatcherServletPathHttpMethodConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(HttpMethod.GET, "/path", "/spring", denyAll) + authorize(HttpMethod.PUT, "/path", "/spring", denyAll) + } + } + return http.build() + } + + @RestController + internal class PathController { + @RequestMapping("/path") + fun path() { + } + } + } + + @Test + fun `request when both authorizeRequests and authorizeHttpRequests configured then exception`() { + assertThatThrownBy { this.spring.register(BothAuthorizeRequestsConfig::class.java).autowire() } + .isInstanceOf(UnsatisfiedDependencyException::class.java) + .hasRootCauseInstanceOf(IllegalStateException::class.java) + .hasMessageContaining( + "authorizeHttpRequests cannot be used in conjunction with authorizeRequests. Please select just one." + ) + } + + @EnableWebSecurity + @EnableWebMvc + open class BothAuthorizeRequestsConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeRequests { + authorize(anyRequest, permitAll) + } + authorizeHttpRequests { + authorize(anyRequest, denyAll) + } + } + return http.build() + } + } +} diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDslTests.kt index 860afdf567..5064ab4d4f 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDslTests.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2022 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. @@ -53,6 +53,9 @@ import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.request.MockMvcRequestBuilders import org.springframework.web.servlet.config.annotation.EnableWebMvc import jakarta.servlet.Filter +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.security.web.SecurityFilterChain /** * Tests for [HttpSecurityDsl] @@ -128,9 +131,13 @@ class HttpSecurityDslTests { } } - @Test - fun `request when it does not match the security request matcher then the security rules do not apply`() { - this.spring.register(SecurityRequestMatcherConfig::class.java).autowire() + @ParameterizedTest + @ValueSource(classes = [ + SecurityRequestMatcherRequestsConfig::class, + SecurityRequestMatcherHttpRequestsConfig::class + ]) + fun `request when it does not match the security request matcher then the security rules do not apply`(config: Class<*>) { + this.spring.register(config).autowire() this.mockMvc.get("/") .andExpect { @@ -138,9 +145,13 @@ class HttpSecurityDslTests { } } - @Test - fun `request when it matches the security request matcher then the security rules apply`() { - this.spring.register(SecurityRequestMatcherConfig::class.java).autowire() + @ParameterizedTest + @ValueSource(classes = [ + SecurityRequestMatcherRequestsConfig::class, + SecurityRequestMatcherHttpRequestsConfig::class + ]) + fun `request when it matches the security request matcher then the security rules apply`(config: Class<*>) { + this.spring.register(config).autowire() this.mockMvc.get("/path") .andExpect { @@ -149,7 +160,7 @@ class HttpSecurityDslTests { } @EnableWebSecurity - open class SecurityRequestMatcherConfig : WebSecurityConfigurerAdapter() { + open class SecurityRequestMatcherRequestsConfig : WebSecurityConfigurerAdapter() { override fun configure(http: HttpSecurity) { http { securityMatcher(RegexRequestMatcher("/path", null)) @@ -160,9 +171,27 @@ class HttpSecurityDslTests { } } - @Test - fun `request when it does not match the security pattern matcher then the security rules do not apply`() { - this.spring.register(SecurityPatternMatcherConfig::class.java).autowire() + @EnableWebSecurity + open class SecurityRequestMatcherHttpRequestsConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + securityMatcher(RegexRequestMatcher("/path", null)) + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + } + return http.build() + } + } + + @ParameterizedTest + @ValueSource(classes = [ + SecurityPatternMatcherRequestsConfig::class, + SecurityPatternMatcherHttpRequestsConfig::class + ]) + fun `request when it does not match the security pattern matcher then the security rules do not apply`(config: Class<*>) { + this.spring.register(config).autowire() this.mockMvc.get("/") .andExpect { @@ -170,9 +199,13 @@ class HttpSecurityDslTests { } } - @Test - fun `request when it matches the security pattern matcher then the security rules apply`() { - this.spring.register(SecurityPatternMatcherConfig::class.java).autowire() + @ParameterizedTest + @ValueSource(classes = [ + SecurityPatternMatcherRequestsConfig::class, + SecurityPatternMatcherHttpRequestsConfig::class + ]) + fun `request when it matches the security pattern matcher then the security rules apply`(config: Class<*>) { + this.spring.register(config).autowire() this.mockMvc.get("/path") .andExpect { @@ -182,7 +215,7 @@ class HttpSecurityDslTests { @EnableWebSecurity @EnableWebMvc - open class SecurityPatternMatcherConfig : WebSecurityConfigurerAdapter() { + open class SecurityPatternMatcherRequestsConfig : WebSecurityConfigurerAdapter() { override fun configure(http: HttpSecurity) { http { securityMatcher("/path") @@ -193,9 +226,28 @@ class HttpSecurityDslTests { } } - @Test - fun `security pattern matcher when used with security request matcher then both apply`() { - this.spring.register(MultiMatcherConfig::class.java).autowire() + @EnableWebSecurity + @EnableWebMvc + open class SecurityPatternMatcherHttpRequestsConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + securityMatcher("/path") + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + } + return http.build() + } + } + + @ParameterizedTest + @ValueSource(classes = [ + MultiMatcherRequestsConfig::class, + MultiMatcherHttpRequestsConfig::class + ]) + fun `security pattern matcher when used with security request matcher then both apply`(config: Class<*>) { + this.spring.register(config).autowire() this.mockMvc.get("/path1") .andExpect { @@ -215,7 +267,7 @@ class HttpSecurityDslTests { @EnableWebSecurity @EnableWebMvc - open class MultiMatcherConfig : WebSecurityConfigurerAdapter() { + open class MultiMatcherRequestsConfig : WebSecurityConfigurerAdapter() { override fun configure(http: HttpSecurity) { http { securityMatcher("/path1") @@ -227,28 +279,48 @@ class HttpSecurityDslTests { } } - @Test - fun `authentication manager when configured in DSL then used`() { - this.spring.register(AuthenticationManagerConfig::class.java).autowire() + @EnableWebSecurity + @EnableWebMvc + open class MultiMatcherHttpRequestsConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + securityMatcher("/path1") + securityMatcher(RegexRequestMatcher("/path2", null)) + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + } + return http.build() + } + } + + @ParameterizedTest + @ValueSource(classes = [ + AuthenticationManagerRequestsConfig::class, + AuthenticationManagerHttpRequestsConfig::class + ]) + fun `authentication manager when configured in DSL then used`(config: Class<*>) { + this.spring.register(config).autowire() mockkObject(AuthenticationManagerConfig.AUTHENTICATION_MANAGER) every { AuthenticationManagerConfig.AUTHENTICATION_MANAGER.authenticate(any()) } returns TestingAuthenticationToken("user", "test", "ROLE_USER") - val request = MockMvcRequestBuilders.get("/") + val request = MockMvcRequestBuilders.get("/") .with(httpBasic("user", "password")) this.mockMvc.perform(request) verify(exactly = 1) { AuthenticationManagerConfig.AUTHENTICATION_MANAGER.authenticate(any()) } } - @EnableWebSecurity - open class AuthenticationManagerConfig : WebSecurityConfigurerAdapter() { - companion object { - val AUTHENTICATION_MANAGER: AuthenticationManager = ProviderManager(TestingAuthenticationProvider()) - } + object AuthenticationManagerConfig { + val AUTHENTICATION_MANAGER: AuthenticationManager = ProviderManager(TestingAuthenticationProvider()) + } + @EnableWebSecurity + open class AuthenticationManagerRequestsConfig : WebSecurityConfigurerAdapter() { override fun configure(http: HttpSecurity) { http { - authenticationManager = AUTHENTICATION_MANAGER + authenticationManager = AuthenticationManagerConfig.AUTHENTICATION_MANAGER authorizeRequests { authorize(anyRequest, authenticated) } @@ -257,6 +329,21 @@ class HttpSecurityDslTests { } } + @EnableWebSecurity + open class AuthenticationManagerHttpRequestsConfig { + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authenticationManager = AuthenticationManagerConfig.AUTHENTICATION_MANAGER + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + httpBasic { } + } + return http.build() + } + } + @Test fun `HTTP security when custom filter configured then custom filter added to filter chain`() { this.spring.register(CustomFilterConfig::class.java).autowire()