diff --git a/README.md b/README.md index 3e696cf40..6caea0a69 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,27 @@ Then publish the jar files to mavenLocal for usage: ### Spring Boot Auto-Configuration -The plugin automatically excludes 7 Spring Boot security auto-configuration classes that conflict with the Grails Spring Security plugin. No manual `spring.autoconfigure.exclude` entries are needed. +The plugin automatically excludes Spring Boot's servlet security auto-configuration classes that conflict with the Grails Spring Security plugin. No manual `spring.autoconfigure.exclude` entries are needed. -To disable this automatic exclusion (e.g. if you want to use Spring Boot's security auto-configuration directly), add the following to `application.yml`: +**Configuration contract:** while this exclusion is enabled (the default), `grails.plugin.springsecurity.*` is the authoritative configuration source for the application's security. Spring Boot's `spring.security.*` properties are *not* merged into the plugin configuration and are *not* applied by Boot's auto-configuration. Use the plugin's keys, not Spring Boot's, to configure security when this plugin is active. + +#### Coexistence with the component-based Spring Security configuration model + +Spring Security 5.7 deprecated and Spring Security 6 removed `WebSecurityConfigurerAdapter`, replacing it with a component-based configuration model that registers individual `@Bean` components (see [Spring Security without the WebSecurityConfigurerAdapter](https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter)). This plugin pre-dates that model and provides equivalent functionality through the `grails.plugin.springsecurity.*` configuration namespace, but it now **blends** the most common component-based patterns automatically. You can configure security from either side (or both) and the effective configuration is the union of both sources. + +| Spring component-based pattern | Blending behaviour with this plugin active | Disable via | +|---|---|---| +| `@Bean SecurityFilterChain` | **Auto-merged** into the plugin's `FilterChainProxy`; user chains are prepended (higher precedence) so their typically more-specific request matchers win against the plugin's catch-all chain. | `grails.plugin.springsecurity.componentBased.autoMergeSecurityFilterChain: false` | +| `@Bean AuthenticationProvider` | **Auto-merged** into the plugin's `authenticationManager` (`ProviderManager`); user providers are appended so the plugin's primary GORM-backed provider runs first; providers already declared via `providerNames` are not re-added. | `grails.plugin.springsecurity.componentBased.autoMergeAuthenticationProviders: false` | +| `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` (or any extra `UserDetailsService`) | For each additional `UserDetailsService` bean, a new `DaoAuthenticationProvider` is created and **appended** to the plugin's `authenticationManager` providers list. The plugin's primary GORM-backed provider runs first; if it does not authenticate the user, each additional provider is tried in turn. (Spring Security 7 made `DaoAuthenticationProvider.userDetailsService` final, so we add new providers instead of mutating the existing one.) | `grails.plugin.springsecurity.componentBased.autoChainUserDetailsServices: false` | +| `spring.security.user.name` / `spring.security.user.password` / `spring.security.user.roles` | If `spring.security.user.name` is set, an `InMemoryUserDetailsManager` is created from those properties (mimicking what Spring Boot's `UserDetailsServiceAutoConfiguration` would have done), wrapped in a `DaoAuthenticationProvider`, and added to the plugin's authenticationManager. | `grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: false` | +| `@Bean WebSecurityCustomizer` | Still a no-op. The plugin does not use Spring's `WebSecurity` builder. To exclude URLs from security checks, use `grails.plugin.springsecurity.staticRules` with `permitAll`, or `grails.plugin.springsecurity.ipRestrictions`. | n/a | +| `@Bean AuthenticationManager` | Conflicts with the plugin's `authenticationManager` (`ProviderManager`) bean by name. Use `@Bean AuthenticationProvider` (auto-merged - see above) or `grails.plugin.springsecurity.providerNames` instead. | n/a | +| LDAP factory beans (`EmbeddedLdapServerContextSourceFactoryBean`, `LdapBindAuthenticationManagerFactory`, `LdapPasswordComparisonAuthenticationManagerFactory`) | Coexist but are not wired into the plugin's authentication providers. | Use the `grails-spring-security-ldap` plugin and the `grails.plugin.springsecurity.ldap.*` configuration. | + +To delegate the entire servlet security stack to Spring Boot's component-based model (and stop using the plugin's `grails.plugin.springsecurity.*` configuration), disable the excluder via the property below. + +To disable this automatic exclusion (e.g. if you want to delegate the entire servlet security stack to Spring Boot instead of the plugin), add the following to `application.yml`: ```yml grails: @@ -63,17 +81,40 @@ grails: excludeSpringSecurityAutoConfiguration: false ``` -If you are on an older version of the plugin that does not support automatic exclusion, you can manually exclude the conflicting classes: +Disabling the exclusion is intentionally a footgun: the plugin can no longer guarantee that its filter chain is the only servlet security stack in the application context, and a startup `WARN` will be logged. + +If you are on an older version of the plugin that does not support automatic exclusion, you can manually exclude the conflicting classes. + +For Grails 8 / Spring Boot 4 (security auto-configurations live under `org.springframework.boot.security.*`): + +```yml +spring: + autoconfigure: + exclude: + - org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration + - org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration + - org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration + - org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration + - org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration + - org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration + - org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration + - org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration + - org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration + - org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration + - org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration +``` + +For Grails 7 / Spring Boot 3 (security auto-configurations live under `org.springframework.boot.autoconfigure.security.*`): ```yml spring: autoconfigure: exclude: - - org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration - - org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration - - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration + - org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration + - org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration + - org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration - org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration - org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration - - org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration + - org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration ``` diff --git a/docs/src/docs/upgrading/upgrading7x.adoc b/docs/src/docs/upgrading/upgrading7x.adoc index 013653747..ba6ea94a3 100644 --- a/docs/src/docs/upgrading/upgrading7x.adoc +++ b/docs/src/docs/upgrading/upgrading7x.adoc @@ -105,7 +105,7 @@ The underlying Spring Security framework has been upgraded from 5.8.x to 6.x. Key changes that may affect your application: * **Jakarta EE namespace**: Spring Security 6 uses `jakarta.servlet` instead of `javax.servlet`. See the https://docs.spring.io/spring-security/reference/migration/index.html[Spring Security Migration Guide] for details. -* **Security filter chain configuration**: The `WebSecurityConfigurerAdapter` has been removed in Spring Security 6. If you have custom security configurations extending this class, you will need to migrate to the component-based approach. The Grails Spring Security plugin handles the core filter chain configuration internally, but any custom Spring Security beans may need updating. +* **Security filter chain configuration**: The `WebSecurityConfigurerAdapter` has been removed in Spring Security 6. If you have custom security configurations extending this class, you will need to migrate to the component-based approach described in https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter[Spring Security without the WebSecurityConfigurerAdapter]. The Grails Spring Security plugin handles the core filter chain configuration internally through the `grails.plugin.springsecurity.*` namespace, so for most apps no migration is needed. However, user-defined `@Bean SecurityFilterChain`, `@Bean WebSecurityCustomizer`, `@Bean AuthenticationManager`, and `@Bean UserDetailsManager` beans are not automatically wired into the plugin's filter chain, authentication providers, or user lookup. See the "Coexistence with the component-based Spring Security configuration model" section in the Installation chapter for the full coexistence matrix and the equivalent plugin configuration for each pattern. * **Default security behaviors**: Some default behaviors have changed in Spring Security 6 (e.g., CSRF protection, session management). Review the https://docs.spring.io/spring-security/reference/migration-7/index.html[Spring Security reference documentation] for the complete list of changes. === Automated Migration diff --git a/gradle.properties b/gradle.properties index 7d6331fd6..8b35e731b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,6 +30,7 @@ gbenchVersion=0.4.3-groovy-2.4 gradleCryptoChecksumVersion=1.4.0 grailsRedisVersion=5.0.1 guavaVersion=33.5.0-jre +jlineReaderVersion=3.30.9 mailVersion=5.0.2 nimbusVersion=10.5 pac4jVersion=6.2.2 diff --git a/plugin-core/docs/src/docs/introduction/installation.adoc b/plugin-core/docs/src/docs/introduction/installation.adoc index 08cfdf21f..ab14d12f0 100644 --- a/plugin-core/docs/src/docs/introduction/installation.adoc +++ b/plugin-core/docs/src/docs/introduction/installation.adoc @@ -74,9 +74,50 @@ dependencies { === Spring Boot Auto-Configuration -The plugin automatically excludes Spring Boot security auto-configuration classes (such as `SecurityAutoConfiguration`, `SecurityFilterAutoConfiguration`, and `UserDetailsServiceAutoConfiguration`) that conflict with the plugin's own security setup. This means you do not need to manually add `spring.autoconfigure.exclude` entries to your `application.yml`. +The plugin automatically excludes Spring Boot's servlet security auto-configuration classes (such as `SecurityAutoConfiguration`, `SecurityFilterAutoConfiguration`, `UserDetailsServiceAutoConfiguration`, the OAuth2 client/resource-server/authorization-server starters, and the SAML2 relying-party starter) that conflict with the plugin's own security setup. This means you do not need to manually add `spring.autoconfigure.exclude` entries to your `application.yml`. -If you need to disable this automatic exclusion - for example, to use Spring Boot's security auto-configuration directly alongside the plugin - set the following property in `application.yml`: +IMPORTANT: While the plugin is active, `grails.plugin.springsecurity.*` is the authoritative configuration namespace for security. Spring Boot's `spring.security.*` properties are not merged into the plugin configuration. Use the plugin's keys to configure security; do not mix them with Boot's `spring.security.*` keys. + +==== Coexistence with the component-based Spring Security configuration model + +Spring Security 5.7 deprecated and Spring Security 6 removed `WebSecurityConfigurerAdapter`, replacing it with a component-based configuration model that registers individual `@Bean` components (see https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter[Spring Security without the WebSecurityConfigurerAdapter]). This plugin pre-dates that model and provides equivalent functionality through the `grails.plugin.springsecurity.*` configuration namespace, but it now *blends* the most common component-based patterns automatically. You can configure security from either side (or both) and the effective configuration is the union of both sources. + +[cols="1,1,1",options="header"] +|=== +| Spring component-based pattern | Blending behaviour with this plugin active | Disable via + +| `@Bean SecurityFilterChain` +| *Auto-merged* into the plugin's `FilterChainProxy`; user chains are prepended (higher precedence) so their typically more-specific request matchers win against the plugin's catch-all chain. +| `grails.plugin.springsecurity.componentBased.autoMergeSecurityFilterChain: false` + +| `@Bean AuthenticationProvider` +| *Auto-merged* into the plugin's `authenticationManager` (`ProviderManager`); user providers are appended so the plugin's primary GORM-backed provider runs first; providers already declared via `providerNames` are not re-added. +| `grails.plugin.springsecurity.componentBased.autoMergeAuthenticationProviders: false` + +| `@Bean InMemoryUserDetailsManager` / `JdbcUserDetailsManager` (or any extra `UserDetailsService`) +| For each additional `UserDetailsService` bean, a new `DaoAuthenticationProvider` is created and *appended* to the plugin's `authenticationManager` providers list. The plugin's primary GORM-backed provider runs first; if it does not authenticate the user, each additional provider is tried in turn. (Spring Security 7 made `DaoAuthenticationProvider.userDetailsService` final, so we add new providers instead of mutating the existing one.) +| `grails.plugin.springsecurity.componentBased.autoChainUserDetailsServices: false` + +| `spring.security.user.name` / `spring.security.user.password` / `spring.security.user.roles` +| If `spring.security.user.name` is set, an `InMemoryUserDetailsManager` is created from those properties (mimicking what Spring Boot's `UserDetailsServiceAutoConfiguration` would have done), wrapped in a `DaoAuthenticationProvider`, and added to the plugin's authenticationManager. +| `grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: false` + +| `@Bean WebSecurityCustomizer` +| Still a no-op. The plugin does not use Spring's `WebSecurity` builder. To exclude URLs from security checks, use `grails.plugin.springsecurity.staticRules` with `permitAll`, or `grails.plugin.springsecurity.ipRestrictions`. +| n/a + +| `@Bean AuthenticationManager` +| Conflicts with the plugin's `authenticationManager` (`ProviderManager`) bean by name. Use `@Bean AuthenticationProvider` (auto-merged - see above) or `grails.plugin.springsecurity.providerNames` instead. +| n/a + +| LDAP factory beans (`EmbeddedLdapServerContextSourceFactoryBean`, `LdapBindAuthenticationManagerFactory`, `LdapPasswordComparisonAuthenticationManagerFactory`) +| Coexist but are not wired into the plugin's authentication providers. +| Use the `grails-spring-security-ldap` plugin and the `grails.plugin.springsecurity.ldap.*` configuration. +|=== + +To delegate the entire servlet security stack to Spring Boot's component-based model (and stop using the plugin's `grails.plugin.springsecurity.*` configuration), disable the excluder via the property below. + +If you need to disable this automatic exclusion - for example, to delegate the entire servlet security stack to Spring Boot instead of the plugin - set the following property in `application.yml`: [source,yaml] ---- @@ -86,6 +127,8 @@ grails: excludeSpringSecurityAutoConfiguration: false ---- +WARNING: Disabling the exclusion is intentionally a footgun. The plugin can no longer guarantee that its filter chain is the only servlet security stack in the application context, and a startup `WARN` will be logged. + === Verifying Installation To verify that the plugin has been successfully installed, you can run a simple test: diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/edit.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/edit.gsp index 1c0c4f0e3..a359d1b63 100644 --- a/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/edit.gsp +++ b/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/edit.gsp @@ -47,7 +47,7 @@
- +
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRole/edit.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRole/edit.gsp index ff434df59..1151cfa54 100644 --- a/plugin-core/examples/functional-test-app/grails-app/views/testRole/edit.gsp +++ b/plugin-core/examples/functional-test-app/grails-app/views/testRole/edit.gsp @@ -46,7 +46,7 @@
- +
diff --git a/plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy b/plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy new file mode 100644 index 000000000..b77633e1d --- /dev/null +++ b/plugin-core/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderIntegrationSpec.groovy @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.plugin.springsecurity + +import spock.lang.Specification + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.web.SecurityFilterChain + +import grails.testing.mixin.integration.Integration + +@Integration +class SecurityAutoConfigurationExcluderIntegrationSpec extends Specification { + + @Autowired + ConfigurableApplicationContext applicationContext + + void "SecurityAutoConfigurationExcluder class is on the classpath"() { + expect: + Class.forName( + 'grails.plugin.springsecurity.SecurityAutoConfigurationExcluder' + ) + } + + void "no Spring Boot SecurityFilterChain bean is registered alongside the plugin"() { + given: 'the application context bean factory' + ConfigurableListableBeanFactory beanFactory = applicationContext.beanFactory + + and: 'all SecurityFilterChain beans visible to the application context' + def filterChainBeans = applicationContext.getBeanNamesForType(SecurityFilterChain) + + expect: 'none come from Spring Boot security auto-configurations' + filterChainBeans.every { name -> + !beanFactory.getBeanDefinition(name).beanClassName?.startsWith('org.springframework.boot.security.') + } + + and: 'none of the excluded auto-configuration class names are registered as beans' + SecurityAutoConfigurationExcluder.excludedAutoConfigurations.each { className -> + assert !beanFactory.containsBeanDefinition(className) : + "Spring Boot auto-configuration ${className} should be excluded by SecurityAutoConfigurationExcluder" + } + } + + void "no Spring Boot in-memory UserDetailsService is registered alongside the plugin"() { + given: 'the application context bean factory' + ConfigurableListableBeanFactory beanFactory = applicationContext.beanFactory + + and: 'all UserDetailsService beans visible to the application context' + def udsBeans = applicationContext.getBeanNamesForType(UserDetailsService) + + expect: 'at least one (the plugin one) exists' + udsBeans.length >= 1 + + and: 'none come from Spring Boot security auto-configurations' + udsBeans.every { name -> + !beanFactory.getBeanDefinition(name).beanClassName?.startsWith('org.springframework.boot.security.') + } + + and: "Boot's in-memory UserDetailsService is not present" + !udsBeans.any { it == 'inMemoryUserDetailsManager' } + } +} diff --git a/plugin-core/plugin/build.gradle b/plugin-core/plugin/build.gradle index 1776c90b7..38f427f1e 100644 --- a/plugin-core/plugin/build.gradle +++ b/plugin-core/plugin/build.gradle @@ -75,6 +75,7 @@ dependencies { compileOnly 'jakarta.servlet:jakarta.servlet-api' // Provided compileOnly 'jline:jline' // for shell commands + compileOnly "org.jline:jline-reader:$jlineReaderVersion" // Required for Groovy static type checking (referenced via Groovy 5.x groovysh by the grails-plugin gradle compiler) compileOnly 'org.apache.grails:grails-core' // Provided as this is a Grails Plugin compileOnly 'org.apache.groovy:groovy' // Compile-time annotations compileOnly 'org.slf4j:slf4j-nop' // Prevents warnings about missing slf4j implementation during compilation diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy index 889e2aa8c..bc810630c 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy @@ -23,6 +23,28 @@ import org.springframework.security.access.hierarchicalroles.RoleHierarchy import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl import org.springframework.security.core.GrantedAuthority +/** + * A mutable {@link RoleHierarchy} that allows the hierarchy definition to be replaced + * at runtime, working around the immutability of Spring Security's + * {@link RoleHierarchyImpl}. + * + *

Spring Security 6 made {@link RoleHierarchyImpl} effectively immutable by + * removing public mutators in favour of {@code RoleHierarchyImpl.fromHierarchy(...)}. + * This class wraps a delegate that is rebuilt every time the hierarchy string is + * assigned, so callers (such as the plugin's {@code roleHierarchy} bean and any + * application code that reads {@code grails.plugin.springsecurity.roleHierarchy} + * at runtime) keep their original setter-based API.

+ * + *

The {@code hierarchy} property uses the standard Spring Security syntax, + * with one assignment per line, e.g.:

+ * + *
+ * ROLE_ADMIN > ROLE_USER
+ * ROLE_USER > ROLE_GUEST
+ * 
+ * + *

Setting the property to {@code null} or an empty string clears the hierarchy.

+ */ @CompileStatic class MutableRoleHierarchy implements RoleHierarchy { diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy new file mode 100644 index 000000000..6cc0d4449 --- /dev/null +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy @@ -0,0 +1,285 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.plugin.springsecurity + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter +import org.springframework.boot.autoconfigure.AutoConfigurationMetadata +import org.springframework.context.EnvironmentAware +import org.springframework.core.env.Environment + +/** + * Automatically excludes Spring Boot security auto-configuration classes that + * conflict with the Grails Spring Security plugin. + * + *

Why this filter exists

+ * + *

The Grails Spring Security plugin owns the servlet security stack of a + * Grails application: it builds its own {@code FilterChainProxy} + * ({@code springSecurityFilterChain}), {@code UserDetailsService} + * ({@code GormUserDetailsService}), and request-mapping/access-decision + * infrastructure from the {@code grails.plugin.springsecurity.*} configuration + * namespace.

+ * + *

Spring Boot ships its own auto-configurations that try to do the same job + * from the {@code spring.security.*} configuration namespace. When both are + * active, Spring Boot can register an additional {@code SecurityFilterChain}, + * an in-memory {@code UserDetailsService}, OAuth2 client/authorization-server + * filter chains, etc., resulting in two parallel servlet security stacks with + * no defined precedence between them.

+ * + *

Configuration contract: while this filter is enabled + * (the default), {@code grails.plugin.springsecurity.*} is the authoritative + * configuration source for the application's security. Boot's + * {@code spring.security.*} properties are not merged into the plugin + * configuration and are not applied by Boot's auto-configuration. + * Use the plugin's keys, not Spring Boot's, to configure security when this + * plugin is active.

+ * + *

Coexistence with the component-based Spring Security configuration model

+ * + *

Spring Security 5.7 deprecated and Spring Security 6 removed + * {@code WebSecurityConfigurerAdapter}, replacing it with a component-based + * configuration model that registers individual {@code @Bean} components + * (see + * Spring Security without the WebSecurityConfigurerAdapter).

+ * + *

This plugin pre-dates that model and provides equivalent functionality + * through the {@code grails.plugin.springsecurity.*} configuration namespace, + * but it now blends the most common component-based patterns + * automatically via {@link grails.plugin.springsecurity.componentbased.ComponentBasedConfigBlender}. + * This means you can configure security from either side (or both) and the + * effective configuration is the union of both sources.

+ * + *

The following table summarises how each component-based pattern is + * blended with the plugin's configuration:

+ * + * + * + *

To delegate the entire servlet security stack to Spring Boot's + * component-based model (and stop using the plugin's + * {@code grails.plugin.springsecurity.*} configuration), disable this filter + * - see the "Opt-out" section below.

+ * + *

What this filter excludes

+ * + *

In Spring Boot 4 the security auto-configurations were moved out of + * {@code spring-boot-autoconfigure} into dedicated {@code spring-boot-security*} + * modules and re-packaged under {@code org.springframework.boot.security.*}. + * Adding any of those starters/modules to a Grails application would otherwise + * re-introduce the conflicting servlet auto-configurations. This filter excludes + * them during Spring Boot's auto-configuration discovery phase, so users do not + * need to maintain a manual {@code spring.autoconfigure.exclude} list.

+ * + *

Opt-out

+ * + *

To disable this filter and allow Spring Boot's security auto-configurations + * to run (for example, to delegate the entire servlet security stack to Spring + * Boot instead of the plugin), set the following property in + * {@code application.yml}:

+ * + *
+ * grails:
+ *   plugin:
+ *     springsecurity:
+ *       excludeSpringSecurityAutoConfiguration: false
+ * 
+ * + *

Disabling this filter is intentionally a footgun: the plugin can no longer + * guarantee that its filter chain is the only servlet security stack in the + * application context, and a startup {@code WARN} is logged when it is turned + * off.

+ * + *

Registered via {@code META-INF/spring.factories} as an + * {@link AutoConfigurationImportFilter}. This runs before auto-configuration + * bytecode is loaded, so there is no performance overhead from excluded classes.

+ * + * @since 7.0.2 + * @see AutoConfigurationImportFilter + */ +@CompileStatic +@Slf4j +class SecurityAutoConfigurationExcluder implements AutoConfigurationImportFilter, EnvironmentAware { + + static final String ENABLED_PROPERTY = 'grails.plugin.springsecurity.excludeSpringSecurityAutoConfiguration' + + private boolean enabled = true + + @Override + void setEnvironment(Environment environment) { + this.enabled = environment.getProperty(ENABLED_PROPERTY, Boolean, true) + if (!this.enabled) { + log.warn( + 'Spring Boot security auto-configuration exclusion is DISABLED via {}=false. ' + + 'Spring Boot may now register a parallel servlet security stack alongside the ' + + 'Grails Spring Security plugin (additional SecurityFilterChain, UserDetailsService, ' + + 'or OAuth2/SAML2 filter chains). The plugin can no longer guarantee that its ' + + 'filter chain is the only servlet security stack in the application context.', + ENABLED_PROPERTY) + } + } + + /** + * Spring Boot 4 servlet security auto-configuration classes that conflict + * with the Grails Spring Security plugin's servlet security stack. These + * are excluded unconditionally when the plugin is on the classpath. + * + *

Verified against the + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} + * files in the following Spring Boot 4 modules: + * {@code spring-boot-security}, {@code spring-boot-security-oauth2-client}, + * {@code spring-boot-security-oauth2-resource-server}, + * {@code spring-boot-security-saml2}, and + * {@code spring-boot-security-oauth2-authorization-server}.

+ * + *

Reactive variants (e.g. {@code ReactiveWebSecurityAutoConfiguration}, + * {@code ReactiveOAuth2ResourceServerAutoConfiguration}) are intentionally + * NOT excluded here. They are guarded by + * {@code @ConditionalOnWebApplication(REACTIVE)} and do not activate in a + * standard servlet-based Grails application; mixed servlet/reactive + * security is outside this plugin's threat model.

+ * + * + */ + private static final Set EXCLUDED_AUTO_CONFIGURATIONS = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration', + 'org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration', + ].toSet().asImmutable() + + @Override + boolean[] match(String[] autoConfigurationClasses, AutoConfigurationMetadata autoConfigurationMetadata) { + autoConfigurationClasses.collect { + !enabled || !(it in EXCLUDED_AUTO_CONFIGURATIONS) + } as boolean[] + } + + /** + * Returns the set of auto-configuration class names that this filter excludes. + * Exposed for testing and diagnostic purposes. + * + * @return unmodifiable set of excluded class names + */ + static Set getExcludedAutoConfigurations() { + EXCLUDED_AUTO_CONFIGURATIONS + } +} diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy index 69e9755fb..9791d97ae 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy @@ -36,7 +36,7 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean @CompileStatic class SpringSecurityBeanFactoryPostProcessor implements BeanFactoryPostProcessor { - protected static final String AUTOCONFIG_NAME = 'org.springframework.boot.autoconfigure.security.SecurityFilterAutoConfiguration' + protected static final String AUTOCONFIG_NAME = 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration' protected static final String SECURITY_PROPERTIES_NAME = 'securityProperties' void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy index 2715b1216..4206bdf3b 100644 --- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy @@ -25,6 +25,7 @@ import grails.plugin.springsecurity.access.vote.ClosureVoter import grails.plugin.springsecurity.authentication.GrailsAnonymousAuthenticationProvider import grails.plugin.springsecurity.authentication.NullAuthenticationEventPublisher import grails.plugin.springsecurity.cache.SpringUserCacheFactoryBean +import grails.plugin.springsecurity.componentbased.ComponentBasedConfigBlender import grails.plugin.springsecurity.userdetails.DefaultPostAuthenticationChecks import grails.plugin.springsecurity.userdetails.DefaultPreAuthenticationChecks import grails.plugin.springsecurity.userdetails.GormUserDetailsService @@ -80,6 +81,7 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent import org.springframework.security.core.context.SecurityContextHolder as SCH import org.springframework.security.core.userdetails.UserDetailsByNameServiceWrapper +import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.cache.NullUserCache import org.springframework.security.crypto.argon2.Argon2PasswordEncoder import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder @@ -696,6 +698,8 @@ to default to 'Annotation'; setting value to 'Annotation' applicationContext.authenticationManager.providers = createBeanList(providerNames) log.trace 'AuthenticationProviders: {}', applicationContext.authenticationManager.providers + applyComponentBasedConfigBlending conf, applicationContext, securityFilterChains + // build handlers list here to give dependent plugins a chance to register some def logoutHandlerNames = (conf.logout.handlerNames ?: SpringSecurityUtils.logoutHandlerNames) + (conf.logout.additionalHandlerNames ?: []) @@ -769,6 +773,57 @@ to default to 'Annotation'; setting value to 'Annotation' private createBeanList(names) { names.collect { name -> applicationContext.getBean(name) } } + private void applyComponentBasedConfigBlending(conf, applicationContext, securityFilterChains) { + def cb = conf.componentBased + boolean mergeFilterChains = cb?.containsKey('autoMergeSecurityFilterChain') ? cb.autoMergeSecurityFilterChain : true + boolean mergeProviders = cb?.containsKey('autoMergeAuthenticationProviders') ? cb.autoMergeAuthenticationProviders : true + boolean chainUds = cb?.containsKey('autoChainUserDetailsServices') ? cb.autoChainUserDetailsServices : true + boolean bridgeUserProps = cb?.containsKey('bridgeSpringSecurityUserProperties') ? cb.bridgeSpringSecurityUserProperties : true + + if (mergeFilterChains) { + ComponentBasedConfigBlender.mergeUserSecurityFilterChains applicationContext, securityFilterChains + } + + if (mergeProviders) { + ComponentBasedConfigBlender.mergeUserAuthenticationProviders applicationContext, applicationContext.authenticationManager + } + + if (chainUds || bridgeUserProps) { + def primary = applicationContext.userDetailsService + List additional = [] + + if (bridgeUserProps) { + def env = applicationContext.environment + String userName = env.getProperty('spring.security.user.name', String) + String userPassword = env.getProperty('spring.security.user.password', String) + List userRoles = env.getProperty('spring.security.user.roles', List) + def bridged = ComponentBasedConfigBlender.bridgeSpringSecurityUserProperties(userName, userPassword, userRoles) + if (bridged != null) { + additional << (UserDetailsService) bridged + } + } + + if (chainUds) { + def others = applicationContext.getBeansOfType(UserDetailsService).values().findAll { it !== primary } + additional.addAll(others) + } + + if (additional) { + def passwordEncoder = applicationContext.containsBean('passwordEncoder') ? applicationContext.passwordEncoder : null + List additionalProviders = additional.collect { UserDetailsService uds -> + def provider = new DaoAuthenticationProvider(uds) + if (passwordEncoder != null) { + provider.passwordEncoder = passwordEncoder + } + provider + } + applicationContext.authenticationManager.providers.addAll additionalProviders + log.info 'Added {} DaoAuthenticationProvider(s) for additional UserDetailsService sources to authenticationManager (the plugin GORM-backed provider remains primary)', + additionalProviders.size() + } + } + } + private configureLogout = { conf -> securityContextLogoutHandler(classFor('securityContextLogoutHandler', SecurityContextLogoutHandler)) { diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy new file mode 100644 index 000000000..0e9f545c8 --- /dev/null +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.plugin.springsecurity.componentbased + +import groovy.transform.CompileStatic + +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException + +/** + * A {@link UserDetailsService} that delegates to a fixed ordered list of + * delegate services. The first delegate that successfully resolves the + * username wins; if every delegate throws + * {@link UsernameNotFoundException}, this service rethrows that exception. + * + *

Used by {@link ComponentBasedConfigBlender#chainUserDetailsServices} to + * blend the Grails plugin's primary {@code userDetailsService} (the GORM-backed + * {@code GormUserDetailsService}) with user-defined + * {@link org.springframework.security.provisioning.InMemoryUserDetailsManager} + * or {@link org.springframework.security.provisioning.JdbcUserDetailsManager} + * beans defined per + * + * Spring Security without the WebSecurityConfigurerAdapter.

+ * + * @since 8.0.0 + */ +@CompileStatic +class ChainedUserDetailsService implements UserDetailsService { + + final List delegates + + ChainedUserDetailsService(List delegates) { + if (!delegates) { + throw new IllegalArgumentException('delegates must not be empty') + } + this.delegates = delegates.asImmutable() + } + + @Override + UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + UsernameNotFoundException lastException = null + for (UserDetailsService delegate in delegates) { + try { + UserDetails details = delegate.loadUserByUsername(username) + if (details != null) { + return details + } + } + catch (UsernameNotFoundException ex) { + lastException = ex + } + } + throw lastException ?: new UsernameNotFoundException("User '${username}' not found in any chained UserDetailsService") + } +} diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy new file mode 100644 index 000000000..13c780936 --- /dev/null +++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.plugin.springsecurity.componentbased + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.context.ApplicationContext +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.ProviderManager +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain + +/** + * Blends user-defined Spring Security configuration components (the patterns + * recommended by + * + * Spring Security without the WebSecurityConfigurerAdapter) into the + * Grails Spring Security plugin's runtime structures. + * + *

The Grails plugin pre-dates the component-based model and owns the servlet + * security stack via its own {@code FilterChainProxy}, {@code ProviderManager} + * and {@code GormUserDetailsService}. This blender lets users keep using the + * blog post's idioms ({@code @Bean SecurityFilterChain}, + * {@code @Bean AuthenticationProvider}, {@code spring.security.user.*}) and + * have them coexist with the plugin's {@code grails.plugin.springsecurity.*} + * configuration instead of being silently ignored.

+ * + *

Each merge method is idempotent and safe to invoke multiple times.

+ * + *

Each merge is enabled by default and can be disabled individually via + * configuration:

+ * + *
+ * grails:
+ *   plugin:
+ *     springsecurity:
+ *       componentBased:
+ *         autoMergeSecurityFilterChain: false       # disable user @Bean SecurityFilterChain merge
+ *         autoMergeAuthenticationProviders: false   # disable user @Bean AuthenticationProvider merge
+ *         autoChainUserDetailsServices: false       # disable user @Bean UserDetailsService chaining
+ *         bridgeSpringSecurityUserProperties: false # disable spring.security.user.* property bridge
+ * 
+ * + * @since 8.0.0 + */ +@CompileStatic +@Slf4j +class ComponentBasedConfigBlender { + + /** + * Adds user-defined {@link SecurityFilterChain} beans to the plugin's filter + * chain list. User chains are prepended (higher precedence) + * because their request matchers are typically more specific than the + * plugin's catch-all chain. + * + *

The plugin's own chains are not registered as named beans (they are + * appended directly to {@code securityFilterChains} in + * {@code SpringSecurityUtils.buildFilterChains}), so every + * {@code SecurityFilterChain} bean visible to the application context is + * treated as user-defined.

+ * + * @param applicationContext the application context to scan + * @param pluginChains the plugin's mutable {@code securityFilterChains} list + * (the same list the plugin's {@code FilterChainProxy} references) + * @return the number of user chains merged + */ + static int mergeUserSecurityFilterChains(ApplicationContext applicationContext, + List pluginChains) { + Map beanMap = applicationContext.getBeansOfType(SecurityFilterChain) + List userChains = beanMap.values().toList() + if (userChains) { + pluginChains.addAll(0, userChains) + log.info 'Auto-merged {} user-defined SecurityFilterChain beans into the plugin filter chain (prepended for precedence): {}', + userChains.size(), beanMap.keySet() + } + userChains.size() + } + + /** + * Adds user-defined {@link AuthenticationProvider} beans to the plugin's + * {@link ProviderManager}. User providers are appended so + * that the plugin's primary providers (typically the GORM-backed DAO + * provider) are tried first. + * + *

Providers already present in the manager (for example, those declared + * via {@code grails.plugin.springsecurity.providerNames}) are not + * re-added.

+ * + * @param applicationContext the application context to scan + * @param authenticationManager the plugin's {@code authenticationManager} + * bean (a {@code ProviderManager}) + * @return the number of user providers merged + */ + static int mergeUserAuthenticationProviders(ApplicationContext applicationContext, + ProviderManager authenticationManager) { + Map beanMap = applicationContext.getBeansOfType(AuthenticationProvider) + Set existing = authenticationManager.providers as Set + List userProviders = beanMap.values().findAll { !(it in existing) }.toList() + if (userProviders) { + authenticationManager.providers.addAll(userProviders) + Set mergedNames = beanMap.findAll { it.value in userProviders }.keySet() + log.info 'Auto-merged {} user-defined AuthenticationProvider beans into the plugin authenticationManager (appended): {}', + userProviders.size(), mergedNames + } + userProviders.size() + } + + /** + * Wraps the plugin's primary {@code UserDetailsService} bean in a chain that + * also queries every other user-defined {@link UserDetailsService} bean in + * the application context. The plugin's bean is queried first; user beans + * are queried in bean-name order if the plugin's bean throws + * {@link org.springframework.security.core.userdetails.UsernameNotFoundException}. + * + *

This method does not modify the plugin's bean directly. Instead it + * returns a {@link UserDetailsService} that callers can substitute in their + * authentication providers if they want the chained behaviour. The Grails + * plugin core does not currently rewire its providers to use the chained + * UDS automatically; users who want this behaviour should declare the + * returned service as a bean and reference it via + * {@code grails.plugin.springsecurity.dao.userDetailsService} (or the + * provider-specific equivalent).

+ * + * @param applicationContext the application context to scan + * @param primaryUserDetailsService the plugin's primary + * {@code userDetailsService} bean (typically + * {@code GormUserDetailsService}) + * @return a chained {@code UserDetailsService}, or the primary unchanged if + * no other user beans are present + */ + static UserDetailsService chainUserDetailsServices(ApplicationContext applicationContext, + UserDetailsService primaryUserDetailsService) { + Map beanMap = applicationContext.getBeansOfType(UserDetailsService) + List additional = beanMap.values() + .findAll { it !== primaryUserDetailsService } + .toList() + if (!additional) { + return primaryUserDetailsService + } + List chain = [primaryUserDetailsService] + additional + log.info 'Chaining {} additional UserDetailsService beans behind the plugin primary: {}', + additional.size(), beanMap.findAll { it.value in additional }.keySet() + new ChainedUserDetailsService(chain) + } + + /** + * If {@code spring.security.user.name} (and optionally + * {@code spring.security.user.password} / {@code spring.security.user.roles}) + * are set, returns an {@link InMemoryUserDetailsManager} containing that + * single user, mimicking what Spring Boot's + * {@code UserDetailsServiceAutoConfiguration} would have created had it not + * been excluded by {@code SecurityAutoConfigurationExcluder}. + * + *

Defaults follow Spring Boot's defaults:

+ *
    + *
  • {@code password} - {@code user} (literal)
  • + *
  • {@code roles} - {@code [USER]}
  • + *
+ * + *

The returned manager is intended to be combined with + * {@link #chainUserDetailsServices} so it coexists with the plugin's primary + * {@code userDetailsService}. Returns {@code null} if + * {@code spring.security.user.name} is not set.

+ * + * @param userName the resolved {@code spring.security.user.name} value + * @param userPassword the resolved {@code spring.security.user.password} + * value (may be {@code null}) + * @param userRoles the resolved {@code spring.security.user.roles} value + * (may be {@code null}) + * @return the bridged {@code InMemoryUserDetailsManager}, or {@code null} if + * the bridge is not applicable + */ + static InMemoryUserDetailsManager bridgeSpringSecurityUserProperties(String userName, + String userPassword, List userRoles) { + if (!userName) { + return null + } + String password = userPassword ?: 'user' + String[] roles = (userRoles ?: ['USER']) as String[] + UserDetails user = User.builder() + .username(userName) + .password('{noop}' + password) + .roles(roles) + .build() + log.info 'Bridging spring.security.user.* properties: created in-memory user "{}" with roles {}', + userName, roles.toList() + new InMemoryUserDetailsManager([user]) + } +} diff --git a/plugin-core/plugin/src/main/resources/META-INF/spring.factories b/plugin-core/plugin/src/main/resources/META-INF/spring.factories new file mode 100644 index 000000000..b778c86de --- /dev/null +++ b/plugin-core/plugin/src/main/resources/META-INF/spring.factories @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +# Automatically exclude Spring Boot security auto-configurations that conflict +# with the Grails Spring Security plugin's bean definitions. +# See: SecurityAutoConfigurationExcluder javadoc for the full list and rationale. +org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\ + grails.plugin.springsecurity.SecurityAutoConfigurationExcluder diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy new file mode 100644 index 000000000..3a7298da4 --- /dev/null +++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy @@ -0,0 +1,273 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.plugin.springsecurity + +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll + +import org.springframework.core.env.Environment + +/** + * Tests for {@link SecurityAutoConfigurationExcluder}. + * + * Verifies that Spring Boot 4 servlet security auto-configuration classes that + * conflict with the Grails Spring Security plugin are filtered out during the + * auto-configuration discovery phase. + */ +class SecurityAutoConfigurationExcluderSpec extends Specification { + + @Subject + SecurityAutoConfigurationExcluder excluder = new SecurityAutoConfigurationExcluder() + + @Unroll + def "match excludes conflicting auto-configuration: #className"() { + given: + def autoConfigs = [className] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'the conflicting auto-configuration is excluded (false = filtered out)' + !results[0] + + where: + className << [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration', + 'org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration', + ] + } + + @Unroll + def "match preserves non-security auto-configuration: #className"() { + given: + def autoConfigs = [className] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'non-security auto-configurations pass through (true = included)' + results[0] + + where: + className << [ + 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration', + 'org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration', + 'org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration', + 'org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration', + 'org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration', + ] + } + + @Unroll + def "match preserves Spring Boot 3 (pre-move) security auto-configuration class names: #className"() { + given: 'these legacy class names are no longer registered as auto-configurations in Spring Boot 4' + def autoConfigs = [className] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'the filter is conservative and only excludes the verified Spring Boot 4 names' + results[0] + + where: + className << [ + 'org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration', + 'org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration', + ] + } + + @Unroll + def "match preserves reactive Spring Boot 4 security auto-configuration: #className"() { + given: 'reactive variants are not excluded; they are guarded by ConditionalOnWebApplication(REACTIVE)' + def autoConfigs = [className] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'the filter is servlet-only and lets reactive variants pass through' + results[0] + + where: + className << [ + 'org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.reactive.ReactiveWebSecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.actuate.web.reactive.ReactiveManagementWebSecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.rsocket.RSocketSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientWebSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.resource.autoconfigure.reactive.ReactiveOAuth2ResourceServerAutoConfiguration', + ] + } + + def "match handles mixed array of included and excluded auto-configurations"() { + given: + def autoConfigs = [ + 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + 'org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: + results[0] // DataSource - included + !results[1] // SecurityAutoConfiguration - excluded + results[2] // Jackson - included + !results[3] // SecurityFilterAutoConfiguration - excluded + results[4] // DispatcherServlet - included + } + + def "match handles empty array"() { + given: + def autoConfigs = [] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: + results.length == 0 + } + + def "match handles null metadata parameter gracefully"() { + given: 'autoConfigurationMetadata is null (not used by this filter)' + def autoConfigs = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'still works correctly' + !results[0] + } + + def "getExcludedAutoConfigurations returns all 11 known conflicting classes"() { + when: + def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations + + then: + excluded.size() == 11 + excluded.contains('org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration') + excluded.contains('org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration') + excluded.contains('org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration') + excluded.contains('org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration') + excluded.contains('org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration') + excluded.contains('org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration') + } + + def "getExcludedAutoConfigurations returns unmodifiable set"() { + when: + def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations + excluded.add('some.new.AutoConfiguration') + + then: + thrown(UnsupportedOperationException) + } + + def "match allows all auto-configurations when disabled via environment property"() { + given: + def env = Mock(Environment) + env.getProperty(SecurityAutoConfigurationExcluder.ENABLED_PROPERTY, Boolean, true) >> false + excluder.environment = env + + and: + def autoConfigs = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'all auto-configurations pass through when filter is disabled' + results[0] + results[1] + results[2] + } + + def "match excludes by default when environment has the property set to true"() { + given: + def env = Mock(Environment) + env.getProperty(SecurityAutoConfigurationExcluder.ENABLED_PROPERTY, Boolean, true) >> true + excluder.environment = env + + and: + def autoConfigs = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'exclusion is active by default' + !results[0] + } + + def "match excludes by default when no environment is set"() { + given: 'excluder without environment (e.g. unit test usage)' + def autoConfigs = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'exclusion is active by default' + !results[0] + } + + def "spring.factories registers the filter correctly"() { + when: 'enumerating all spring.factories resources on the classpath' + def resources = getClass().classLoader.getResources('META-INF/spring.factories') + def allContents = resources.collect { it.text } + + then: 'at least one spring.factories exists' + !allContents.isEmpty() + + and: 'one of them registers SecurityAutoConfigurationExcluder as an AutoConfigurationImportFilter' + allContents.any { content -> + content.contains('org.springframework.boot.autoconfigure.AutoConfigurationImportFilter') && + content.contains('grails.plugin.springsecurity.SecurityAutoConfigurationExcluder') + } + } +} diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy new file mode 100644 index 000000000..7814dc33f --- /dev/null +++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 grails.plugin.springsecurity.componentbased + +import spock.lang.Specification +import spock.lang.Subject + +import org.springframework.context.ApplicationContext +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.ProviderManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain + +class ComponentBasedConfigBlenderSpec extends Specification { + + @Subject + ComponentBasedConfigBlender blender = new ComponentBasedConfigBlender() + + def "mergeUserSecurityFilterChains prepends user beans (higher precedence)"() { + given: + SecurityFilterChain pluginChain = Stub(SecurityFilterChain) + SecurityFilterChain userChain1 = Stub(SecurityFilterChain) + SecurityFilterChain userChain2 = Stub(SecurityFilterChain) + ApplicationContext ctx = Mock(ApplicationContext) + ctx.getBeansOfType(SecurityFilterChain) >> [user1: userChain1, user2: userChain2] + + List pluginChains = [pluginChain] + + when: + int merged = ComponentBasedConfigBlender.mergeUserSecurityFilterChains(ctx, pluginChains) + + then: + merged == 2 + pluginChains.size() == 3 + pluginChains[0].is(userChain1) + pluginChains[1].is(userChain2) + pluginChains[2].is(pluginChain) + } + + def "mergeUserSecurityFilterChains with no user beans is a no-op"() { + given: + SecurityFilterChain pluginChain = Stub(SecurityFilterChain) + ApplicationContext ctx = Mock(ApplicationContext) + ctx.getBeansOfType(SecurityFilterChain) >> [:] + + List pluginChains = [pluginChain] + + when: + int merged = ComponentBasedConfigBlender.mergeUserSecurityFilterChains(ctx, pluginChains) + + then: + merged == 0 + pluginChains.size() == 1 + pluginChains[0].is(pluginChain) + } + + def "mergeUserAuthenticationProviders appends user beans (plugin providers run first)"() { + given: + AuthenticationProvider pluginProvider = Stub(AuthenticationProvider) + AuthenticationProvider userProvider1 = Stub(AuthenticationProvider) + AuthenticationProvider userProvider2 = Stub(AuthenticationProvider) + ApplicationContext ctx = Mock(ApplicationContext) + ctx.getBeansOfType(AuthenticationProvider) >> [ + pluginP: pluginProvider, // simulates plugin's provider also being a named bean + userP1: userProvider1, + userP2: userProvider2, + ] + + ProviderManager mgr = new ProviderManager([pluginProvider]) + + when: + int merged = ComponentBasedConfigBlender.mergeUserAuthenticationProviders(ctx, mgr) + + then: 'only beans NOT already in the manager are merged' + merged == 2 + mgr.providers.size() == 3 + mgr.providers[0].is(pluginProvider) + mgr.providers[1].is(userProvider1) + mgr.providers[2].is(userProvider2) + } + + def "mergeUserAuthenticationProviders skips providers already in manager (idempotent)"() { + given: + AuthenticationProvider pluginProvider = Stub(AuthenticationProvider) + AuthenticationProvider userProvider = Stub(AuthenticationProvider) + ApplicationContext ctx = Mock(ApplicationContext) + ctx.getBeansOfType(AuthenticationProvider) >> [pluginP: pluginProvider, userP: userProvider] + + ProviderManager mgr = new ProviderManager([pluginProvider, userProvider]) + + when: + int merged = ComponentBasedConfigBlender.mergeUserAuthenticationProviders(ctx, mgr) + + then: + merged == 0 + mgr.providers.size() == 2 + } + + def "chainUserDetailsServices returns primary unchanged when no other UDS beans"() { + given: + UserDetailsService primary = Stub(UserDetailsService) + ApplicationContext ctx = Mock(ApplicationContext) + ctx.getBeansOfType(UserDetailsService) >> [primary: primary] + + when: + def result = ComponentBasedConfigBlender.chainUserDetailsServices(ctx, primary) + + then: + result.is(primary) + } + + def "chainUserDetailsServices wraps primary + additional in a chain"() { + given: + UserDetailsService primary = Stub(UserDetailsService) { + loadUserByUsername('alice') >> { throw new UsernameNotFoundException('not in primary') } + loadUserByUsername('bob') >> User.builder().username('bob').password('{noop}b').roles('USER').build() + } + UserDetailsService secondary = Stub(UserDetailsService) { + loadUserByUsername('alice') >> User.builder().username('alice').password('{noop}a').roles('USER').build() + loadUserByUsername('bob') >> { throw new UsernameNotFoundException('not in secondary') } + } + ApplicationContext ctx = Mock(ApplicationContext) + ctx.getBeansOfType(UserDetailsService) >> [primary: primary, secondary: secondary] + + when: + UserDetailsService chained = ComponentBasedConfigBlender.chainUserDetailsServices(ctx, primary) + + then: 'a chained service is returned' + chained instanceof ChainedUserDetailsService + + and: 'primary is queried first (returns its own user without consulting secondary)' + chained.loadUserByUsername('bob').username == 'bob' + + and: 'secondary is queried when primary throws UsernameNotFoundException' + chained.loadUserByUsername('alice').username == 'alice' + } + + def "ChainedUserDetailsService throws UsernameNotFoundException when no delegate finds the user"() { + given: + UserDetailsService primary = Stub(UserDetailsService) { + loadUserByUsername(_) >> { throw new UsernameNotFoundException('not in primary') } + } + UserDetailsService secondary = Stub(UserDetailsService) { + loadUserByUsername(_) >> { throw new UsernameNotFoundException('not in secondary') } + } + ChainedUserDetailsService chained = new ChainedUserDetailsService([primary, secondary]) + + when: + chained.loadUserByUsername('carol') + + then: + thrown(UsernameNotFoundException) + } + + def "bridgeSpringSecurityUserProperties returns null when name not set"() { + expect: + ComponentBasedConfigBlender.bridgeSpringSecurityUserProperties(null, null, null) == null + ComponentBasedConfigBlender.bridgeSpringSecurityUserProperties('', null, null) == null + } + + def "bridgeSpringSecurityUserProperties uses Spring Boot defaults when only name is set"() { + when: + InMemoryUserDetailsManager mgr = ComponentBasedConfigBlender + .bridgeSpringSecurityUserProperties('alice', null, null) + + then: + mgr != null + + when: + UserDetails alice = mgr.loadUserByUsername('alice') + + then: + alice.username == 'alice' + alice.password == '{noop}user' + alice.authorities*.authority.toSet() == ['ROLE_USER'] as Set + } + + def "bridgeSpringSecurityUserProperties applies password and roles"() { + when: + InMemoryUserDetailsManager mgr = ComponentBasedConfigBlender + .bridgeSpringSecurityUserProperties('admin', 'secret', ['ADMIN', 'USER']) + + then: + UserDetails admin = mgr.loadUserByUsername('admin') + admin.username == 'admin' + admin.password == '{noop}secret' + admin.authorities*.authority.toSet() == ['ROLE_ADMIN', 'ROLE_USER'] as Set + } +} diff --git a/plugin-oauth2/plugin/build.gradle b/plugin-oauth2/plugin/build.gradle index afff874e1..6033fd54c 100644 --- a/plugin-oauth2/plugin/build.gradle +++ b/plugin-oauth2/plugin/build.gradle @@ -75,6 +75,7 @@ dependencies { implementation 'jline:jline', { // comp: ConsoleReader } + compileOnly "org.jline:jline-reader:$jlineReaderVersion" // Required for Groovy static type checking (referenced via Groovy 5.x groovysh by the grails-plugin gradle compiler) implementation 'org.apache.grails.data:grails-datamapping-core', { // impl: @Transactional(runtime) } diff --git a/spring-security-compat/build.gradle b/spring-security-compat/build.gradle index e5bdf867e..85ab38f1e 100644 --- a/spring-security-compat/build.gradle +++ b/spring-security-compat/build.gradle @@ -26,7 +26,7 @@ group = 'org.apache.grails.security' ext { publishArtifactId = 'grails-spring-security-compat' pomTitle = 'Grails Spring Security Compatibility Module' - pomDescription = 'Compatibility classes for Grails Spring Security when running against newer Spring Security versions.' + pomDescription = 'Compatibility classes that make Grails Spring Security 8 work with newer Spring Security versions, such as 7 and later.' pomDevelopers = [ matrei: 'Mattias Reichel', ] diff --git a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy index ba04584d2..b51a7e937 100644 --- a/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy +++ b/spring-security-compat/src/main/groovy/org/springframework/security/access/intercept/AbstractSecurityInterceptor.groovy @@ -23,6 +23,14 @@ import groovy.transform.CompileStatic import org.springframework.security.access.AccessDecisionManager import org.springframework.security.authentication.AuthenticationManager +/** + * Based on the class of the same name in Spring Security, removed in + * Spring Security 7. This compatibility shim keeps the property-bag API + * (authenticationManager, accessDecisionManager, securityMetadataSource, etc.) + * that the Grails Spring Security plugin still relies on, so subclasses such as + * the plugin's filter-security and method-security interceptors continue to + * compile and run unchanged. + */ @CompileStatic abstract class AbstractSecurityInterceptor {