diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml
index ee5a47234..cd6eea16a 100644
--- a/.github/workflows/gradle.yml
+++ b/.github/workflows/gradle.yml
@@ -27,6 +27,9 @@ permissions:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
+env:
+ JAVA_DISTRIBUTION: liberica
+ JAVA_VERSION: 21
jobs:
coreTests:
if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
@@ -37,8 +40,8 @@ jobs:
- name: "☕️ Setup JDK"
uses: actions/setup-java@v4
with:
- java-version: 17
- distribution: liberica
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: "🐘 Setup Gradle"
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
with:
@@ -50,7 +53,6 @@ jobs:
--max-workers=2
--refresh-dependencies
--continue
- -PgebAtCheckWaiting
functionalTests:
if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }}
runs-on: ubuntu-24.04
@@ -64,8 +66,8 @@ jobs:
- name: "☕️ Setup JDK"
uses: actions/setup-java@v4
with:
- java-version: 17
- distribution: liberica
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: "🐘 Setup Gradle"
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
with:
@@ -75,7 +77,6 @@ jobs:
./gradlew
core-examples-functional-test-app:check
-DTESTCONFIG=${{ matrix.test-config }}
- -PgebAtCheckWaiting
publish:
needs: [ coreTests, functionalTests ]
if: ${{ always() && github.repository_owner == 'apache' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && (needs.coreTests.result == 'success' || needs.coreTests.result == 'skipped') && (needs.functionalTests.result == 'success' || needs.functionalTests.result == 'skipped') }}
@@ -90,8 +91,8 @@ jobs:
- name: "☕️ Setup JDK"
uses: actions/setup-java@v4
with:
- java-version: 17
- distribution: liberica
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
- name: "🐘 Setup Gradle"
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
with:
diff --git a/.github/workflows/rat.yml b/.github/workflows/rat.yml
index fed8dcbbc..c3a8f04fe 100644
--- a/.github/workflows/rat.yml
+++ b/.github/workflows/rat.yml
@@ -26,6 +26,9 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
+env:
+ JAVA_DISTRIBUTION: liberica
+ JAVA_VERSION: 21
jobs:
rat-audit:
runs-on: ubuntu-latest
@@ -35,8 +38,8 @@ jobs:
- name: "☕️ Setup JDK"
uses: actions/setup-java@v4
with:
- distribution: liberica
- java-version: 17
+ distribution: ${{ env.JAVA_DISTRIBUTION }}
+ java-version: ${{ env.JAVA_VERSION }}
- name: "🐘 Setup Gradle"
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5551cd6c7..83196c174 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,7 +24,7 @@ env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GRAILS_PUBLISH_RELEASE: true
JAVA_DISTRIBUTION: liberica
- JAVA_VERSION: 17.0.17 # this must be a specific version for reproducible builds, keep it synced with .sdkmanrc
+ JAVA_VERSION: 21.0.10 # this must be a specific version for reproducible builds, keep it synced with .sdkmanrc
PROJECT_DESC: >
Apache Grails Spring Security adds production-ready
authentication and authorization to Apache Grails applications.
diff --git a/.sdkmanrc b/.sdkmanrc
index 84839beb1..f84f394c1 100644
--- a/.sdkmanrc
+++ b/.sdkmanrc
@@ -1,4 +1,4 @@
-java=17.0.17-librca
+java=21.0.10-librca
gradle=8.14.4
# This is here to support the test app generation in the *rest projects
grails=7.0.7
\ No newline at end of file
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/build.gradle b/build.gradle
index ddc91d366..7237b1a7b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -55,15 +55,6 @@ allprojects {
}
}
}
-
- configurations.configureEach {
- resolutionStrategy.eachDependency { DependencyResolveDetails details ->
- if (details.requested.group == 'org.seleniumhq.selenium') {
- details.useVersion('4.25.0')
- details.because('Temporary workaround because of https://issues.chromium.org/issues/42323769')
- }
- }
- }
}
subprojects {
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 31ec4f4e4..8b35e731b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -18,7 +18,7 @@
#
projectVersion=8.0.0-SNAPSHOT
grailsVersion=8.0.0-SNAPSHOT
-javaVersion=17
+javaVersion=21
unboundidLdapSdkVersion=7.0.3
apacheDsVersion=1.5.4
@@ -30,8 +30,8 @@ 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
-micronautVersion=4.10.7
nimbusVersion=10.5
pac4jVersion=6.2.2
ratVersion=0.8.1
diff --git a/gradle/publish-root-config.gradle b/gradle/publish-root-config.gradle
index d2fdcf65f..0ad9e720b 100644
--- a/gradle/publish-root-config.gradle
+++ b/gradle/publish-root-config.gradle
@@ -29,6 +29,7 @@ def publishedProjects = [
'core-plugin',
'ldap-plugin',
'oauth2-plugin',
+ 'spring-security-compat',
'spring-security-rest',
'spring-security-rest-gorm',
'spring-security-rest-grailscache',
diff --git a/gradle/test-config.gradle b/gradle/test-config.gradle
index 47067d7b4..5e156c604 100644
--- a/gradle/test-config.gradle
+++ b/gradle/test-config.gradle
@@ -48,10 +48,9 @@ tasks.named('integrationTest', Test) {
// systemProperty('grails.geb.recording.mode', 'RECORD_ALL')
systemProperty('TESTCONFIG', System.getProperty('TESTCONFIG'))
- // Make Geb tests more resilient in slow CI environments
- if (project.hasProperty('gebAtCheckWaiting')) {
- systemProperty('grails.geb.atCheckWaiting.enabled', 'true')
- }
+ // Enable atCheckWaiting for Geb tests
+ systemProperty('grails.geb.atCheckWaiting.enabled', true)
+ systemProperty('grails.geb.timeouts.timeout', 10)
doFirst {
logger.quiet(
diff --git a/plugin-acl/examples/functional-test-app/grails-app/controllers/com/testacl/ErrorsController.groovy b/plugin-acl/examples/functional-test-app/grails-app/controllers/com/testacl/ErrorsController.groovy
index 235400160..bc54ee072 100644
--- a/plugin-acl/examples/functional-test-app/grails-app/controllers/com/testacl/ErrorsController.groovy
+++ b/plugin-acl/examples/functional-test-app/grails-app/controllers/com/testacl/ErrorsController.groovy
@@ -27,7 +27,7 @@ import groovy.transform.CompileStatic
class ErrorsController {
def error404() {
- String uri = 'request.forwardURI'
+ String uri = request.forwardURI ?: request.requestURI ?: 'unknown URI'
if (!uri.contains('favicon.ico')) {
println "\n\nERROR 404: could not find $uri\n\n"
}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy
new file mode 100644
index 000000000..39036bfd4
--- /dev/null
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/AccessDeniedPage.groovy
@@ -0,0 +1,26 @@
+/*
+ * 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 pages
+
+import geb.Page
+
+class AccessDeniedPage extends Page {
+
+ static at = { $('h1').text() == 'Access Denied' }
+}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/DeleteReportPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/DeleteReportPage.groovy
new file mode 100644
index 000000000..8d00b11da
--- /dev/null
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/DeleteReportPage.groovy
@@ -0,0 +1,29 @@
+/*
+ * 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 pages
+
+class DeleteReportPage extends ScaffoldPage {
+
+ static url = '/report/delete'
+
+ String convertToPath(Object[] args) {
+ args ? "?number=${args[0]}" : ''
+ }
+}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/EditReportPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/EditReportPage.groovy
index 8cf01042b..4d3d374a9 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/EditReportPage.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/EditReportPage.groovy
@@ -19,13 +19,22 @@
package pages
+import geb.module.TextInput
+
class EditReportPage extends ScaffoldPage {
+ static url = '/report/edit'
+
static at = {
- heading.text() == 'Edit Report'
+ heading == 'Edit Report'
+ }
+
+ String convertToPath(Object[] args) {
+ args ? "?number=${args[0]}" : ''
}
static content = {
+ nameField { $('input', name: 'name').module(TextInput) }
updateButton { $('input', value: 'Update') }
}
}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ListReportPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ListReportPage.groovy
index d0f508eb9..b6e4327dd 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ListReportPage.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ListReportPage.groovy
@@ -23,24 +23,44 @@ import geb.Module
class ListReportPage extends ScaffoldPage {
- static url = 'report/list?max=1000'
+ static url = 'report/list'
static at = {
title ==~ /Report List/
}
+ String convertToPath(Object[] args) {
+ if (!args) {
+ return ''
+ }
+
+ def params = args[0] as Map
+ if (!params) {
+ return ''
+ }
+
+ if (!params.containsKey('max')) {
+ params.max = 1000
+ }
+
+ '?' + params.collect { key, value ->
+ "${URLEncoder.encode(key.toString(), 'UTF-8')}=" +
+ "${URLEncoder.encode(value?.toString() ?: '', 'UTF-8')}"
+ }.join('&')
+ }
static content = {
+ message { $('div.message').text() }
+ nextLink { $('.nextLink') }
reportTable { $('div.list table', 0) }
- reportRow { i -> module ReportRow, reportRows[i] }
- reportRows(required: false) { reportTable.find('tbody').find('tr') }
+ reportRows { reportTable.find('tbody tr').moduleList(ReportRow) }
}
}
class ReportRow extends Module {
static content = {
- cell { i -> $('td', i) }
- cellText { i -> cell(i).text() }
- cellHrefText{ i -> cell(i).find('a').text() }
+ cell { int i -> $('td', i) }
+ cellText { int i -> cell(i).text() }
+ cellHrefText { int i -> cell(i).find('a').text() }
name { cellText(1) }
showLink(to: ShowReportPage) { cell(0).find('a') }
grantLink(to: ReportGrantPage) { cell(2).find('a') }
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LoginPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LoginPage.groovy
index 1120f3004..f40adf2fb 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LoginPage.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LoginPage.groovy
@@ -23,7 +23,7 @@ import geb.Page
class LoginPage extends Page {
- static url = 'login/auth'
+ static url = '/login/auth'
static at = { title == 'Login' }
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LogoutPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LogoutPage.groovy
new file mode 100644
index 000000000..0f3a90cf2
--- /dev/null
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/LogoutPage.groovy
@@ -0,0 +1,27 @@
+/*
+ * 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 pages
+
+import geb.Page
+
+class LogoutPage extends Page {
+
+ static url = '/logout'
+}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ReportGrantPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ReportGrantPage.groovy
index c32c5e589..43ff38c53 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ReportGrantPage.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ReportGrantPage.groovy
@@ -19,13 +19,31 @@
package pages
+import geb.module.TextInput
+
+import org.springframework.security.acls.model.Permission
+
class ReportGrantPage extends ScaffoldPage {
+ static url = '/report/grant'
+
static at = {
- heading.text() ==~ /Grant permission for.+/
+ heading ==~ /Grant permission for.+/
+ }
+
+ String convertToPath(Object[] args) {
+ args ? "?number=${args[0]}" : ''
}
static content = {
grantButton { $('input', value: 'Grant') }
+ permissionInput { $('input', name: 'permission').module(TextInput) }
+ recipientInput { $('input', name: 'recipient').module(TextInput) }
+ }
+
+ void grantPermission(String recipient, Permission permission) {
+ recipientInput.value(recipient)
+ permissionInput.value(permission.mask)
+ grantButton.click()
}
}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ResetDataPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ResetDataPage.groovy
new file mode 100644
index 000000000..eb2125847
--- /dev/null
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ResetDataPage.groovy
@@ -0,0 +1,31 @@
+/*
+ * 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 pages
+
+import geb.Page
+
+class ResetDataPage extends Page {
+
+ static url = '/testData/reset'
+
+ static at = {
+ $().text() == 'OK'
+ }
+}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy
index c402eedf1..4e6a5b492 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy
@@ -24,7 +24,8 @@ import geb.Page
class ScaffoldPage extends Page {
static content = {
- heading { $('h1') }
+ h1 { $('h1', 0) }
+ heading { h1.text() }
message { $('div.message').text() }
}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ShowReportPage.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ShowReportPage.groovy
index f19bf4666..76b747a98 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ShowReportPage.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/pages/ShowReportPage.groovy
@@ -21,8 +21,14 @@ package pages
class ShowReportPage extends ScaffoldPage {
+ static url = '/report/show'
+
static at = {
- heading.text() == 'Show Report'
+ heading == 'Show Report'
+ }
+
+ String convertToPath(Object[] args) {
+ args ? "?number=${args[0]}" : ''
}
static content = {
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AbstractSecuritySpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AbstractSecuritySpec.groovy
index b063da4ad..eeb6800f0 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AbstractSecuritySpec.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AbstractSecuritySpec.groovy
@@ -16,39 +16,42 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package test
+import pages.IndexPage
+import pages.ResetDataPage
+
import grails.gorm.transactions.Rollback
import grails.plugin.geb.ContainerGebSpec
-import grails.testing.mixin.integration.Integration
import pages.LoginPage
+import pages.LogoutPage
import spock.lang.Shared
@Rollback
-@Integration
abstract class AbstractSecuritySpec extends ContainerGebSpec {
@Shared boolean reset = false
void setup() {
if (!reset) {
- go('testData/reset')
+ to(ResetDataPage)
reset = true
}
logout()
}
protected void login(String user) {
- to(LoginPage).with {
+ via(LoginPage).with {
username = user
password = 'password'
loginButton.click()
}
+ at(IndexPage)
}
protected void logout() {
- go('logout')
+ via(LogoutPage)
+ at(IndexPage)
clearCookies()
}
}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AdminFunctionalSpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AdminFunctionalSpec.groovy
index 7c3e813cc..d5792696a 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AdminFunctionalSpec.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/AdminFunctionalSpec.groovy
@@ -16,20 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package test
-import grails.testing.mixin.integration.Integration
-import org.springframework.security.acls.domain.BasePermission
-
+import pages.DeleteReportPage
import pages.EditReportPage
import pages.IndexPage
import pages.ListReportPage
import pages.ReportGrantPage
import pages.ShowReportPage
-import spock.lang.Stepwise
+import spock.lang.Unroll
+
+import grails.testing.mixin.integration.Integration
+
+import static org.springframework.security.acls.domain.BasePermission.READ
-@Stepwise
@Integration
class AdminFunctionalSpec extends AbstractSecuritySpec {
@@ -41,54 +41,55 @@ class AdminFunctionalSpec extends AbstractSecuritySpec {
void 'check tags'() {
when:
- go('tagLibTest/test')
+ go('/tagLibTest/test')
then:
- pageSource.contains('test 1 true 1')
- pageSource.contains('test 2 true 1')
- pageSource.contains('test 3 true 1')
- pageSource.contains('test 4 true 1')
- pageSource.contains('test 5 true 1')
- pageSource.contains('test 6 true 1')
-
- pageSource.contains('test 1 true 13')
- pageSource.contains('test 2 true 13')
- pageSource.contains('test 3 true 13')
- pageSource.contains('test 4 true 13')
- pageSource.contains('test 5 true 13')
- pageSource.contains('test 6 true 13')
-
- pageSource.contains('test 1 true 80')
- pageSource.contains('test 2 true 80')
- pageSource.contains('test 3 true 80')
- pageSource.contains('test 4 true 80')
- pageSource.contains('test 5 true 80')
- pageSource.contains('test 6 true 80')
+ with(pageSource) {
+ contains('test 1 true 1')
+ contains('test 2 true 1')
+ contains('test 3 true 1')
+ contains('test 4 true 1')
+ contains('test 5 true 1')
+ contains('test 6 true 1')
+
+ contains('test 1 true 13')
+ contains('test 2 true 13')
+ contains('test 3 true 13')
+ contains('test 4 true 13')
+ contains('test 5 true 13')
+ contains('test 6 true 13')
+
+ contains('test 1 true 80')
+ contains('test 2 true 80')
+ contains('test 3 true 80')
+ contains('test 4 true 80')
+ contains('test 5 true 80')
+ contains('test 6 true 80')
+ }
}
+ @Unroll
void 'view all'() {
when:
- go("report/show?number=$i")
+ def page = to(ShowReportPage, i)
then:
- pageSource.contains("report$i")
+ page.name == "report$i"
where:
i << (1..100)
}
void 'edit report 15'() {
-
when:
- go('report/edit?number=15')
+ def page = to(EditReportPage, 15)
then:
- at(EditReportPage)
- $('form').name == 'report15'
+ page.nameField.text == 'report15'
when:
- name = 'report15_new'
- updateButton.click()
+ page.nameField = 'report15_new'
+ page.updateButton.click()
then:
at(ShowReportPage)
@@ -97,34 +98,33 @@ class AdminFunctionalSpec extends AbstractSecuritySpec {
void 'delete report 15'() {
when:
- go('report/delete?number=15')
- def listReportPage = at(ListReportPage)
+ via(DeleteReportPage, 15)
then:
- message == 'Report 15 deleted'
- listReportPage.reportRows.size() == 99
+ def page = at(ListReportPage)
+
+ and:
+ page.message == 'Report 15 deleted'
+ page.reportRows.size() == 99
}
void 'grant edit 16'() {
when:
- go('report/grant?number=16')
- def reportGrantPage = at(ReportGrantPage)
+ def page = to(ReportGrantPage, 16)
then:
- pageSource.contains('Grant permission for report16')
+ page.heading == 'Grant permission for report16'
when:
- recipient = 'user2'
- permission = BasePermission.READ.mask.toString()
- reportGrantPage.grantButton.click()
- at(ShowReportPage)
+ page.grantPermission('user2', READ)
+ page = at(ShowReportPage)
then:
- pageSource.contains("Permission $BasePermission.READ.mask granted on Report 16 to user2")
+ page.message == "Permission $READ.mask granted on Report 16 to user2"
// login as user2 and verify the grant
when:
- go('logout')
+ logout()
then:
at(IndexPage)
@@ -132,13 +132,10 @@ class AdminFunctionalSpec extends AbstractSecuritySpec {
when:
login('user2')
- then:
- at(IndexPage)
-
- when:
- go('report/show?number=16')
+ and:
+ page = to(ShowReportPage, 16)
then:
- pageSource.contains('report16')
+ page.name == 'report16'
}
}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy
index 73a67aaa3..6a7614711 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User1FunctionalSpec.groovy
@@ -16,19 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package test
-import grails.testing.mixin.integration.Integration
-import org.springframework.security.acls.domain.BasePermission
-
+import pages.AccessDeniedPage
+import pages.DeleteReportPage
import pages.EditReportPage
import pages.ListReportPage
import pages.ReportGrantPage
import pages.ShowReportPage
-import spock.lang.Stepwise
+import spock.lang.Unroll
+
+import grails.testing.mixin.integration.Integration
+
+import static org.springframework.security.acls.domain.BasePermission.READ
+import static org.springframework.security.acls.domain.BasePermission.WRITE
-@Stepwise
@Integration
class User1FunctionalSpec extends AbstractSecuritySpec {
@@ -40,144 +42,137 @@ class User1FunctionalSpec extends AbstractSecuritySpec {
void 'check tags'() {
when:
- go('tagLibTest/test')
+ go('/tagLibTest/test')
then:
- pageSource.contains('test 1 true 1')
- pageSource.contains('test 2 true 1')
- pageSource.contains('test 3 true 1')
- pageSource.contains('test 4 true 1')
- pageSource.contains('test 5 true 1')
- pageSource.contains('test 6 true 1')
-
- pageSource.contains('test 1 true 13')
- pageSource.contains('test 2 true 13')
- pageSource.contains('test 3 true 13')
- pageSource.contains('test 4 true 13')
- pageSource.contains('test 5 true 13')
- pageSource.contains('test 6 true 13')
-
- pageSource.contains('test 1 false 80')
- pageSource.contains('test 2 false 80')
- pageSource.contains('test 3 false 80')
- pageSource.contains('test 4 false 80')
- pageSource.contains('test 5 false 80')
- pageSource.contains('test 6 false 80')
+ with(pageSource) {
+ contains('test 1 true 1')
+ contains('test 2 true 1')
+ contains('test 3 true 1')
+ contains('test 4 true 1')
+ contains('test 5 true 1')
+ contains('test 6 true 1')
+
+ contains('test 1 true 13')
+ contains('test 2 true 13')
+ contains('test 3 true 13')
+ contains('test 4 true 13')
+ contains('test 5 true 13')
+ contains('test 6 true 13')
+
+ contains('test 1 false 80')
+ contains('test 2 false 80')
+ contains('test 3 false 80')
+ contains('test 4 false 80')
+ contains('test 5 false 80')
+ contains('test 6 false 80')
+ }
}
+ @Unroll
void 'view all (1-67)'() {
when:
- go("report/show?number=$i")
+ def page = to(ShowReportPage, i)
then:
- pageSource.contains("report$i")
+ page.name == "report$i"
where:
i << (1..67)
}
+ @Unroll
void 'view all (68-100)'() {
when:
- go("report/show?number=$i")
+ via(ShowReportPage, i)
then:
- pageSource.contains('Access Denied')
+ at(AccessDeniedPage)
where:
- i << (68..100)
+ i << (68..69)
}
void 'edit report 11'() {
when:
- go('report/edit?number=11')
- def editPage = at(EditReportPage)
+ def page = to(EditReportPage, 11)
then:
- $('form').name == 'report11'
+ page.nameField.text == 'report11'
when:
- name = 'report11_new'
- editPage.updateButton.click()
+ page.nameField.text = 'report11_new'
+ page.updateButton.click()
+ page = at(ShowReportPage)
then:
- at(ShowReportPage)
- pageSource.contains('report11_new')
+ page.name == 'report11_new'
}
void 'delete report 11'() {
when:
- go('report/delete?number=11')
- def listPage = at(ListReportPage)
+ via(DeleteReportPage, 11)
+ def page = at(ListReportPage)
then:
- message == 'Report 11 deleted'
- listPage.reportRows.size() == 66
+ page.message == 'Report 11 deleted'
+ page.reportRows.size() == 66
}
void 'grant edit 12'() {
when:
- go('report/grant?number=12')
- def grantPage = at(ReportGrantPage)
+ def page = to(ReportGrantPage, 12)
then:
- pageSource.contains('Grant permission for report12')
+ page.heading == 'Grant permission for report12'
when:
- recipient = 'user2'
- permission = BasePermission.READ.mask.toString()
- grantPage.grantButton.click()
+ page.grantPermission('user2', READ)
+ page = at(ShowReportPage)
then:
- at(ShowReportPage)
- pageSource.contains("Permission $BasePermission.READ.mask granted on Report 12 to user2")
+ page.message == "Permission $READ.mask granted on Report 12 to user2"
when:
- go('report/grant?number=12')
- grantPage = at(ReportGrantPage)
+ page = to(ReportGrantPage, 12)
then:
- pageSource.contains('Grant permission for report12')
+ page.heading == 'Grant permission for report12'
when:
- recipient = 'user2'
- permission = BasePermission.WRITE.mask.toString()
- grantPage.grantButton.click()
+ page.grantPermission('user2', WRITE)
then:
at(ShowReportPage)
- pageSource.contains("Permission $BasePermission.WRITE.mask granted on Report 12 to user2")
+ page.message == "Permission $WRITE.mask granted on Report 12 to user2"
}
void 'grant edit 13'() {
when:
- go('report/grant?number=13')
- def grantPage = at(ReportGrantPage)
+ def page = to(ReportGrantPage, 13)
then:
- pageSource.contains('Grant permission for report13')
+ page.heading == 'Grant permission for report13'
when:
- recipient = 'user2'
- permission = BasePermission.WRITE.mask.toString()
- grantPage.grantButton.click()
+ page.grantPermission('user2', WRITE)
then:
- pageSource.contains('Access Denied')
+ at(AccessDeniedPage)
}
void 'edit report 20'() {
when:
- go('report/edit?number=20')
- def editPage = at(EditReportPage)
+ def page = to(EditReportPage, 20)
then:
- $('form').name == 'report20'
+ page.nameField.text == 'report20'
when:
- name = 'report20_new'
- editPage.updateButton.click()
+ page.nameField.text = 'report20_new'
+ page.updateButton.click()
then:
- pageSource.contains('Access Denied')
+ at(AccessDeniedPage)
}
}
diff --git a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User2FunctionalSpec.groovy b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User2FunctionalSpec.groovy
index bda6c2330..c2803dcaa 100644
--- a/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User2FunctionalSpec.groovy
+++ b/plugin-acl/examples/functional-test-app/src/integration-test/groovy/test/User2FunctionalSpec.groovy
@@ -16,17 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package test
-import grails.testing.mixin.integration.Integration
-import org.springframework.security.acls.domain.BasePermission
+import pages.AccessDeniedPage
+import pages.DeleteReportPage
import pages.EditReportPage
+import pages.ListReportPage
import pages.ReportGrantPage
import pages.ShowReportPage
-import spock.lang.Stepwise
+import spock.lang.Unroll
+
+import grails.testing.mixin.integration.Integration
+
+import static org.springframework.security.acls.domain.BasePermission.WRITE
-@Stepwise
@Integration
class User2FunctionalSpec extends AbstractSecuritySpec {
@@ -36,120 +39,118 @@ class User2FunctionalSpec extends AbstractSecuritySpec {
login('user2')
}
+ @Unroll
void 'view all (1-5)'() {
when:
- go("report/show?number=$i")
+ def page = to(ShowReportPage, 1)
then:
- pageSource.contains("report$i")
+ page.name == 'report1'
where:
i << (1..5)
}
+ @Unroll
void 'view all (6-100)'() {
when:
- go("report/show?number=$i")
+ via(ShowReportPage, i)
then:
- pageSource.contains('Access Denied')
+ at(AccessDeniedPage)
where:
i << (6..100)
}
void 'edit report 11'() {
-
when:
- go('report/edit?number=11')
+ via(EditReportPage, 11)
then:
- pageSource.contains('Access Denied')
+ at(AccessDeniedPage)
}
void 'delete report 1'() {
when:
- go('report/delete?number=1')
+ via(DeleteReportPage, 1)
then:
- pageSource.contains('Access Denied')
+ at(AccessDeniedPage)
}
void 'grant edit 2'() {
when:
- go('report/grant?number=2')
- def grantPage = at(ReportGrantPage)
+ def page = to(ReportGrantPage, 2)
then:
- pageSource.contains('Grant permission for report2')
+ page.heading == 'Grant permission for report2'
when:
- recipient = 'user1'
- permission = BasePermission.WRITE.mask.toString()
- grantPage.grantButton.click()
+ page.grantPermission('user1', WRITE)
then:
- pageSource.contains('Access Denied')
+ at(AccessDeniedPage)
}
void 'edit report 5'() {
when:
- go('report/edit?number=5')
- def editPage = at(EditReportPage)
+ def page = to(EditReportPage, 5)
then:
- $('form').name == 'report5'
+ page.nameField.text == 'report5'
when:
- name = 'report5_new'
- editPage.updateButton.click()
+ page.nameField.text = 'report5_new'
+ page.updateButton.click()
+ page = at(ShowReportPage)
then:
- at(ShowReportPage)
- pageSource.contains('report5_new')
+ page.name == 'report5_new'
}
void 'list is filtered'() {
-
when:
- go('report/list')
+ def page = to(ListReportPage)
then:
- pageSource.contains('report1')
- !pageSource.contains('report6')
+ page.reportRows[0].name == 'report1'
+ page.reportRows.every {it.name != 'report6' }
when:
- go('report/list?offset=80&max=10')
+ to(ListReportPage, [offset: 80, max: 10])
then:
- pageSource.contains('Next')
- !pageSource.contains('report85')
+ page.nextLink.displayed
+ page.reportRows.every {it.name != 'report85' }
}
void 'check tags'() {
when:
- go('tagLibTest/test')
+ go('/tagLibTest/test')
then:
- pageSource.contains('test 1 true 1')
- pageSource.contains('test 2 true 1')
- pageSource.contains('test 3 true 1')
- pageSource.contains('test 4 true 1')
- pageSource.contains('test 5 true 1')
- pageSource.contains('test 6 true 1')
-
- pageSource.contains('test 1 false 13')
- pageSource.contains('test 2 false 13')
- pageSource.contains('test 3 false 13')
- pageSource.contains('test 4 false 13')
- pageSource.contains('test 5 false 13')
- pageSource.contains('test 6 false 13')
-
- pageSource.contains('test 1 false 80')
- pageSource.contains('test 2 false 80')
- pageSource.contains('test 3 false 80')
- pageSource.contains('test 4 false 80')
- pageSource.contains('test 5 false 80')
- pageSource.contains('test 6 false 80')
+ with(pageSource) {
+ contains('test 1 true 1')
+ contains('test 2 true 1')
+ contains('test 3 true 1')
+ contains('test 4 true 1')
+ contains('test 5 true 1')
+ contains('test 6 true 1')
+
+ contains('test 1 false 13')
+ contains('test 2 false 13')
+ contains('test 3 false 13')
+ contains('test 4 false 13')
+ contains('test 5 false 13')
+ contains('test 6 false 13')
+
+ contains('test 1 false 80')
+ contains('test 2 false 80')
+ contains('test 3 false 80')
+ contains('test 4 false 80')
+ contains('test 5 false 80')
+ contains('test 6 false 80')
+ }
}
}
diff --git a/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceMetadataSpec.groovy b/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceMetadataSpec.groovy
new file mode 100644
index 000000000..ec5b13e4b
--- /dev/null
+++ b/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceMetadataSpec.groovy
@@ -0,0 +1,45 @@
+/*
+ * 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.acl
+
+import grails.util.Holders
+import grails.testing.mixin.integration.Integration
+import grails.gorm.transactions.Rollback
+import org.springframework.security.access.method.MethodSecurityMetadataSource
+
+@Integration
+@Rollback
+class TestClassAnnotatedServiceMetadataSpec extends AbstractIntegrationSpec {
+
+ void 'userAnnotated metadata resolves from the live proxied bean'() {
+ given:
+ def applicationContext = Holders.grailsApplication.mainContext
+ MethodSecurityMetadataSource aclSecurityMetadataSource = applicationContext.getBean('aclSecurityMetadataSource', MethodSecurityMetadataSource)
+ def beanType = applicationContext.getType('testClassAnnotatedService')
+ def beanTypeMethod = beanType.getMethod('userAnnotated')
+
+ expect:
+ aclSecurityMetadataSource.getAttributes(beanTypeMethod, beanType)*.attribute == ['ROLE_USER']
+ }
+}
+
+
+
+
+
diff --git a/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceSpec.groovy b/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceSpec.groovy
index 4fa12c111..ac0d6ee5d 100644
--- a/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceSpec.groovy
+++ b/plugin-acl/examples/integration-test-app/src/integration-test/groovy/grails/plugin/springsecurity/acl/TestClassAnnotatedServiceSpec.groovy
@@ -56,10 +56,10 @@ class TestClassAnnotatedServiceSpec extends AbstractAclSpec {
testClassAnnotatedService.notAnnotated()
then:
- notThrown()
+ noExceptionThrown()
}
- void 'check that the userAnnotated method overides the class annotation and requires ROLE_USER'() {
+ void 'check that the userAnnotated method overides the class annotation and requires authentication'() {
given:
buildReports()
@@ -69,18 +69,29 @@ class TestClassAnnotatedServiceSpec extends AbstractAclSpec {
then:
thrown AuthenticationCredentialsNotFoundException
- when:
+ }
+
+ void 'check that the userAnnotated method overrides the class annotation and denies ROLE_ADMIN'() {
+ given:
+ buildReports()
authenticateAsAdmin()
+
+ when:
testClassAnnotatedService.userAnnotated()
then:
thrown AccessDeniedException
+ }
- when:
+ void 'check that the userAnnotated method overrides the class annotation and allows ROLE_USER'() {
+ given:
+ buildReports()
authenticateAsUser()
+
+ when:
testClassAnnotatedService.userAnnotated()
then:
- notThrown()
+ noExceptionThrown()
}
}
diff --git a/plugin-acl/plugin/build.gradle b/plugin-acl/plugin/build.gradle
index fd91fb707..97a18986f 100644
--- a/plugin-acl/plugin/build.gradle
+++ b/plugin-acl/plugin/build.gradle
@@ -68,6 +68,7 @@ dependencies {
}
api project(':core-plugin')
+ api project(':spring-security-compat')
api 'org.springframework.security:spring-security-acl'
}
diff --git a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy
index 28fa8191e..9a82ae134 100644
--- a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy
+++ b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/SpringSecurityAclGrailsPlugin.groovy
@@ -329,6 +329,7 @@ class SpringSecurityAclGrailsPlugin extends Plugin {
def metadataSources = [
ref('prePostAnnotationSecurityMetadataSource'),
ref('springSecuredAnnotationSecurityMetadataSource'),
+ ref('grailsSecuredAnnotationSecurityMetadataSource'),
ref('serviceStaticMethodSecurityMetadataSource')]
aclSecurityMetadataSource(ProxyAwareDelegatingMethodSecurityMetadataSource) {
methodSecurityMetadataSources = metadataSources
diff --git a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy
index 324af63ab..1a0c6edec 100644
--- a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy
+++ b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/GroovyAwareAclVoter.groovy
@@ -49,7 +49,7 @@ class GroovyAwareAclVoter implements AccessDecisionVoter {
}
boolean supports(Class> clazz) {
- clazz.isAssignableFrom MethodInvocation
+ MethodInvocation.isAssignableFrom(clazz)
}
int vote(Authentication authentication, MethodInvocation object, Collection attributes) {
diff --git a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/ProxyAwareDelegatingMethodSecurityMetadataSource.groovy b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/ProxyAwareDelegatingMethodSecurityMetadataSource.groovy
index 83044ac77..eba7c1cb7 100644
--- a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/ProxyAwareDelegatingMethodSecurityMetadataSource.groovy
+++ b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/ProxyAwareDelegatingMethodSecurityMetadataSource.groovy
@@ -18,6 +18,8 @@
*/
package grails.plugin.springsecurity.acl.access.method
+import groovy.util.logging.Slf4j
+
import grails.plugin.springsecurity.acl.util.ProxyUtils
import groovy.transform.CompileStatic
import org.springframework.beans.factory.InitializingBean
@@ -40,6 +42,7 @@ import java.lang.reflect.Method
* @author Luke Taylor
* @author Burt Beckwith
*/
+@Slf4j
@CompileStatic
class ProxyAwareDelegatingMethodSecurityMetadataSource
extends AbstractMethodSecurityMetadataSource
@@ -82,7 +85,7 @@ implements InitializingBean {
return null
}
- logger.debug "Adding security method [$cacheKey] with attributes $attributes"
+ log.debug "Adding security method [$cacheKey] with attributes $attributes"
cache[cacheKey] = attributes
diff --git a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSource.groovy b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSource.groovy
index 8b7013db7..4b5cb51cb 100644
--- a/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSource.groovy
+++ b/plugin-acl/plugin/src/main/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSource.groovy
@@ -24,6 +24,7 @@ import groovy.transform.CompileStatic
import org.springframework.security.access.ConfigAttribute
import org.springframework.security.access.SecurityConfig
import org.springframework.security.access.method.AbstractFallbackMethodSecurityMetadataSource
+import org.springframework.util.ReflectionUtils
import java.lang.annotation.Annotation
import java.lang.reflect.Method
@@ -49,10 +50,17 @@ class SecuredAnnotationSecurityMetadataSource extends AbstractFallbackMethodSecu
@Override
protected Collection findAttributes(Method method, Class> targetClass) {
- Method actualMethod = ProxyUtils.unproxy(method)
- if (isService(actualMethod.declaringClass)) {
- return processAnnotation(actualMethod.getAnnotation(Secured))
+ def actualClass = targetClass == null ?
+ ProxyUtils.unproxy(method.declaringClass) :
+ ProxyUtils.unproxy(targetClass)
+ if (isService(actualClass)) {
+ def actualMethod = ReflectionUtils.findMethod(actualClass, method.name, method.parameterTypes)
+ if (actualMethod == null) {
+ actualMethod = ProxyUtils.unproxy(method)
+ }
+ return processAnnotation(actualMethod?.getAnnotation(Secured))
}
+ null
}
Collection getAllConfigAttributes() {}
@@ -64,7 +72,14 @@ class SecuredAnnotationSecurityMetadataSource extends AbstractFallbackMethodSecu
}
protected boolean isService(Class> clazz) {
- serviceClassNames.any { String name -> name == clazz.name }
+ def current = clazz
+ while (current != null && current != Object) {
+ if (serviceClassNames.any { it == current.name }) {
+ return true
+ }
+ current = current.superclass
+ }
+ false
}
/**
diff --git a/plugin-acl/plugin/src/test/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSourceSpec.groovy b/plugin-acl/plugin/src/test/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSourceSpec.groovy
new file mode 100644
index 000000000..0b928ff66
--- /dev/null
+++ b/plugin-acl/plugin/src/test/groovy/grails/plugin/springsecurity/acl/access/method/SecuredAnnotationSecurityMetadataSourceSpec.groovy
@@ -0,0 +1,58 @@
+/*
+ * 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.acl.access.method
+
+import grails.plugin.springsecurity.annotation.Secured
+import spock.lang.Specification
+
+class SecuredAnnotationSecurityMetadataSourceSpec extends Specification {
+
+ private SecuredAnnotationSecurityMetadataSource metadataSource = new SecuredAnnotationSecurityMetadataSource(
+ serviceClassNames: [AnnotatedService.name]
+ )
+
+ void 'getAttributes resolves method annotations for proxied or subclassed service targets'() {
+ given:
+ def method = ProxiedAnnotatedService.getMethod('userAnnotated')
+
+ expect:
+ metadataSource.getAttributes(method, ProxiedAnnotatedService)*.attribute == ['ROLE_USER']
+ }
+
+ void 'getAttributes falls back to inherited class annotations for proxied or subclassed service targets'() {
+ given:
+ def method = ProxiedAnnotatedService.getMethod('notAnnotated')
+
+ expect:
+ metadataSource.getAttributes(method, ProxiedAnnotatedService)*.attribute == ['ROLE_ADMIN']
+ }
+
+ @Secured('ROLE_ADMIN')
+ private static class AnnotatedService {
+
+ void notAnnotated() {}
+
+ @Secured('ROLE_USER')
+ void userAnnotated() {}
+ }
+
+ private static class ProxiedAnnotatedService extends AnnotatedService {
+ }
+}
+
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/build.gradle b/plugin-core/examples/functional-test-app/build.gradle
index 11dc139b6..f3dac097a 100644
--- a/plugin-core/examples/functional-test-app/build.gradle
+++ b/plugin-core/examples/functional-test-app/build.gradle
@@ -44,11 +44,9 @@ dependencies {
runtimeOnly 'org.springframework.boot:spring-boot-starter-tomcat'
integrationTestImplementation testFixtures('org.apache.grails:grails-geb')
- integrationTestImplementation "io.micronaut:micronaut-http-client:$micronautVersion"
+ integrationTestImplementation 'org.apache.grails:grails-testing-support-http-client'
integrationTestImplementation 'org.apache.grails:grails-testing-support-web'
integrationTestImplementation 'org.spockframework:spock-core'
-
- integrationTestRuntimeOnly "io.micronaut:micronaut-jackson-databind:$micronautVersion"
}
apply {
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 31c5a9948..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
@@ -40,13 +40,14 @@
-
+
+
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/show.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/show.gsp
index 80c23995e..8bd1ce5fd 100644
--- a/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/show.gsp
+++ b/plugin-core/examples/functional-test-app/grails-app/views/testRequestmap/show.gsp
@@ -53,10 +53,10 @@
-
+
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRole/create.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRole/create.gsp
index 101eafad1..57fe9cb30 100644
--- a/plugin-core/examples/functional-test-app/grails-app/views/testRole/create.gsp
+++ b/plugin-core/examples/functional-test-app/grails-app/views/testRole/create.gsp
@@ -39,12 +39,12 @@
-
+
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 7c514f2d1..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
@@ -40,13 +40,13 @@
-
+
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testRole/show.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testRole/show.gsp
index aab053524..35760c803 100644
--- a/plugin-core/examples/functional-test-app/grails-app/views/testRole/show.gsp
+++ b/plugin-core/examples/functional-test-app/grails-app/views/testRole/show.gsp
@@ -41,10 +41,10 @@
-
+
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testUser/edit.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testUser/edit.gsp
index 1667561b3..86f1a38d8 100644
--- a/plugin-core/examples/functional-test-app/grails-app/views/testUser/edit.gsp
+++ b/plugin-core/examples/functional-test-app/grails-app/views/testUser/edit.gsp
@@ -94,8 +94,8 @@
-
-
+
+
diff --git a/plugin-core/examples/functional-test-app/grails-app/views/testUser/show.gsp b/plugin-core/examples/functional-test-app/grails-app/views/testUser/show.gsp
index a05f70aef..b6d44808b 100644
--- a/plugin-core/examples/functional-test-app/grails-app/views/testUser/show.gsp
+++ b/plugin-core/examples/functional-test-app/grails-app/views/testUser/show.gsp
@@ -75,7 +75,7 @@
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/CreatePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/CreatePage.groovy
index 39b1b9479..e1c36a1db 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/CreatePage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/CreatePage.groovy
@@ -16,10 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages
class CreatePage extends ScaffoldPage {
+
static at = {
title ==~ /Create.+/
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/EditPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/EditPage.groovy
index 8863422b3..293bd4d4d 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/EditPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/EditPage.groovy
@@ -16,11 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages
class EditPage extends ScaffoldPage {
+
static at = {
- heading.text() ==~ /Edit.+/
+ heading ==~ /Edit.+/
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy
index 4b04eba1b..e30d64e6f 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ScaffoldPage.groovy
@@ -16,14 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages
import geb.Page
class ScaffoldPage extends Page {
+
static content = {
- heading { $('h1') }
+ h1 { $('h1') }
+ heading { h1.text() }
message { $('div.message').text() }
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ShowPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ShowPage.groovy
index c6b96296a..db55d0487 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ShowPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/ShowPage.groovy
@@ -16,11 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages
class ShowPage extends ScaffoldPage {
+
static at = {
- heading.text() ==~ /Show .+/
+ heading ==~ /Show .+/
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/CreateRequestmapPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/CreateRequestmapPage.groovy
index f6025b8f6..39f21ae1d 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/CreateRequestmapPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/CreateRequestmapPage.groovy
@@ -16,13 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages.requestmap
+import geb.module.TextInput
import pages.CreatePage
class CreateRequestmapPage extends CreatePage {
+
static content = {
- createButton(to: ShowRequestmapPage) { create() }
+ urlField { $('input', name: 'url').module(TextInput) }
+ configAttributeField { $('input', name: 'configAttribute').module(TextInput) }
+ createButton(to: ShowRequestmapPage) { $('input', type: 'submit') }
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/EditRequestmapPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/EditRequestmapPage.groovy
index c8d81fd1d..55a6a6070 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/EditRequestmapPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/EditRequestmapPage.groovy
@@ -16,13 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages.requestmap
+import geb.module.TextInput
import pages.EditPage
class EditRequestmapPage extends EditPage {
+
static content = {
+ urlField { $('input', name: 'url').module(TextInput) }
+ configAttributeField { $('input', name: 'configAttribute').module(TextInput) }
updateButton(to: ShowRequestmapPage) { $('input', value: 'Update') }
deleteButton(to: ListRequestmapPage) { $('input', value: 'Delete') }
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/ListRequestmapPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/ListRequestmapPage.groovy
index ba4404ad8..d8d4ca8b2 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/ListRequestmapPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/requestmap/ListRequestmapPage.groovy
@@ -34,15 +34,15 @@ class ListRequestmapPage extends ScaffoldPage {
newRequestmapButton(to: CreateRequestmapPage) { $('a', text: 'New TestRequestmap') }
requestmapTable { $('div.content table', 0) }
requestmapRows(required: false) { requestmapTable.find('tbody').find('tr') }
- requestmapRow { i -> requestmapRows[i].module(RequestmapRow) }
+ requestmapRow { int i -> requestmapRows[i].module(RequestmapRow) }
}
}
class RequestmapRow extends Module {
static content = {
- cell { i -> $('td', i) }
- cellText { i -> cell(i).text() }
- cellHrefText{ i -> cell(i).find('a').text() }
+ cell { int i -> $('td', i) }
+ cellText { int i -> cell(i).text() }
+ cellHrefText { int i -> cell(i).find('a').text() }
configAttribute { cellText(1) }
showLink(to: ShowRequestmapPage) { cell(0).find('a') }
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/CreateRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/CreateRolePage.groovy
index 01ae34603..1ecedaab1 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/CreateRolePage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/CreateRolePage.groovy
@@ -16,13 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages.role
+import geb.module.TextInput
import pages.CreatePage
class CreateRolePage extends CreatePage {
+
static content = {
- createButton(to: ShowRolePage) { create() }
+ authorityField { $('input', name: 'authority').module(TextInput) }
+ createButton(to: ShowRolePage) { $('input', name: 'create') }
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy
index 641ec8b00..2d44172f4 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/EditRolePage.groovy
@@ -16,14 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages.role
+import geb.module.TextInput
import pages.EditPage
class EditRolePage extends EditPage {
+
static content = {
+ authorityField { $('input', name: 'authority').module(TextInput) }
updateButton(to: ShowRolePage) { $('input', value: 'Update') }
- deleteButton(to: ListRolePage) { $('input', value: 'Delete') }
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ListRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ListRolePage.groovy
index cb525ce10..ca2272d24 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ListRolePage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ListRolePage.groovy
@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages.role
import geb.Module
@@ -33,16 +32,16 @@ class ListRolePage extends ScaffoldPage {
static content = {
newRoleButton(to: CreateRolePage) { $('a', text: 'New TestRole') }
roleTable { $('div.content table', 0) }
- roleRow { i -> roleRows[i].module RoleRow }
+ roleRow { int i -> roleRows[i].module(RoleRow) }
roleRows(required: false) { roleTable.find('tbody').find('tr') }
}
}
class RoleRow extends Module {
static content = {
- cell { i -> $('td', i) }
- cellText { i -> cell(i).text() }
- cellHrefText{ i -> cell(i).find('a').text() }
+ cell { int i -> $('td', i) }
+ cellText { int i -> cell(i).text() }
+ cellHrefText { int i -> cell(i).find('a').text() }
authority { cellText(0) }
showLink(to: ShowRolePage) { cell(0).find('a') }
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ShowRolePage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ShowRolePage.groovy
index 468a662d3..1cfdd7c66 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ShowRolePage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/role/ShowRolePage.groovy
@@ -16,15 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages.role
import pages.ShowPage
class ShowRolePage extends ShowPage {
+
static content = {
- editButton(to: EditRolePage) { $('a', text: 'Edit') }
- deleteButton(to: ListRolePage) { $('input', value: 'Delete') }
+ editButton { $('a', text: 'Edit') }
+ deleteButton { $('input', value: 'Delete') }
row { String text -> $('li.fieldcontain span.property-label', text: text).parent() }
value { String text -> row(text).find('span.property-value').text() }
authority { value('Authority') }
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/CreateUserPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/CreateUserPage.groovy
index 136e5178a..ee7b0caf1 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/CreateUserPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/CreateUserPage.groovy
@@ -19,10 +19,17 @@
package pages.user
+import geb.module.Checkbox
+import geb.module.PasswordInput
+import geb.module.TextInput
import pages.CreatePage
class CreateUserPage extends CreatePage {
+
static content = {
- createButton(to: ShowUserPage) { create() }
+ usernameField { $('input', name: 'username').module(TextInput) }
+ passwordField { $('input', name: 'password').module(PasswordInput) }
+ enabledCheckbox { $('input', id: 'enabled').module(Checkbox) }
+ createButton(to: ShowUserPage) { $('input', name: 'create') }
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/EditUserPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/EditUserPage.groovy
index 3fcd83ac7..54554f7ec 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/EditUserPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/EditUserPage.groovy
@@ -16,14 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages.user
+import geb.module.Checkbox
+import geb.module.PasswordInput
+import geb.module.TextInput
import pages.EditPage
class EditUserPage extends EditPage {
+
static content = {
- updateButton(to: ShowUserPage) { $('input', value: 'Update') }
- deleteButton(to: ListUserPage) { $('input', value: 'Delete') }
+ usernameField { $('input', name: 'username').module(TextInput) }
+ passwordField { $('input', name: 'password').module(PasswordInput) }
+ enabledCheckbox { $('input', id: 'enabled').module(Checkbox) }
+ updateButton { $('input', value: 'Update') }
+ deleteButton { $('input', value: 'Delete') }
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/ListUserPage.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/ListUserPage.groovy
index 020d35cb0..3c78f9c26 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/ListUserPage.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/pages/user/ListUserPage.groovy
@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package pages.user
import geb.Module
@@ -33,16 +32,16 @@ class ListUserPage extends ScaffoldPage {
static content = {
newUserButton(to: CreateUserPage) { $('a', text: 'New TestUser') }
userTable { $('div.list table', 0) }
- userRow { i -> userRows[i].module UserRow }
+ userRow { int i -> userRows[i].module(UserRow) }
userRows(required: false) { userTable.find('tbody').find('tr') }
}
}
class UserRow extends Module {
static content = {
- cell { i -> $('td', i) }
- cellText { i -> cell(i).text() }
- cellHrefText{ i -> cell(i).find('a').text() }
+ cell { int i -> $('td', i) }
+ cellText { int i -> cell(i).text() }
+ cellHrefText{ int i -> cell(i).find('a').text() }
username { cellText(1) }
userEnabled { 'True' == cellText(2) }
showLink(to: ShowUserPage) { cell(0).find('a') }
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy
index ea78afaec..fd66a51c6 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/BasicAuthSecuritySpec.groovy
@@ -16,12 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package specs
-import spock.lang.Stepwise
-
-import grails.testing.mixin.integration.Integration
import pages.LoginPage
import pages.role.CreateRolePage
import pages.role.ListRolePage
@@ -30,6 +26,9 @@ import pages.user.CreateUserPage
import pages.user.ListUserPage
import pages.user.ShowUserPage
import spock.lang.IgnoreIf
+import spock.lang.Stepwise
+
+import grails.testing.mixin.integration.Integration
@Integration
@Stepwise
@@ -211,7 +210,7 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec {
login('admin1', 'password1')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
@@ -224,7 +223,7 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec {
login('admin1', 'password1')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
@@ -237,35 +236,35 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec {
login('admin1', 'password1')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
getWithAuth('secureClassAnnotated', 'admin1', 'password1')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
getWithAuth('secureClassAnnotated/index', 'admin1', 'password1')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
getWithAuth('secureClassAnnotated/otherAction', 'admin1', 'password1')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
getWithAuth('secureClassAnnotated/admin2', 'admin1', 'password1')
then:
- pageSource.contains('Error 403 Forbidden')
+ waitFor { pageSource.contains('Error 403 Forbidden') }
}
void 'check allowed for admin2'() {
@@ -282,7 +281,7 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec {
login('admin2', 'password2')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
@@ -295,7 +294,7 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec {
login('admin2', 'password2')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
@@ -308,35 +307,35 @@ class BasicAuthSecuritySpec extends AbstractSecuritySpec {
login('admin2', 'password2')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
getWithAuth('secureClassAnnotated', 'admin2', 'password2')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
getWithAuth('secureClassAnnotated/index', 'admin2', 'password2')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
getWithAuth('secureClassAnnotated/otherAction', 'admin2', 'password2')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
getWithAuth('secureClassAnnotated/admin2', 'admin2', 'password2')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
}
protected void logout() {
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/CustomFilterRegistrationSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/CustomFilterRegistrationSpec.groovy
index 10f0f4d25..51d2100e1 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/CustomFilterRegistrationSpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/CustomFilterRegistrationSpec.groovy
@@ -16,26 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package specs
-import grails.testing.mixin.integration.Integration
-import io.micronaut.http.HttpRequest
-import io.micronaut.http.HttpResponse
-import io.micronaut.http.HttpStatus
import spock.lang.IgnoreIf
import spock.lang.Issue
+import spock.lang.Specification
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+@Integration
@IgnoreIf({ System.getProperty('TESTCONFIG') != 'issue503' })
@Issue('https://github.com/apache/grails-spring-security/issues/503')
-@Integration(applicationClass = functional.test.app.Application)
-class CustomFilterRegistrationSpec extends HttpClientSpec {
+class CustomFilterRegistrationSpec extends Specification implements HttpClientSupport {
- void 'GET request to /assets/spinner.gif should not throw error because custom filter is excluded'() {
- when: "A GET request to the assets directory is made"
- HttpResponse response = client.exchange(HttpRequest.GET("/assets/spinner.gif"))
+ void 'GET request to spinner image asset should not throw error because custom filter is excluded'() {
+ when: 'A GET request to the assets directory is made'
+ def response = http('/assets/spinner.gif')
- then: "the filter is not invoked because of the chainMap definition of filters: 'none' in application.groovy"
- response.status == HttpStatus.OK
+ then: 'the filter is not invoked because of the chainMap definition of filters: "none" in application.groovy'
+ response.assertStatus(200)
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/DisableSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/DisableSpec.groovy
index 1aed7a120..be3ebd8a1 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/DisableSpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/DisableSpec.groovy
@@ -16,13 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package specs
-import grails.testing.mixin.integration.Integration
import pages.IndexPage
import spock.lang.IgnoreIf
+import grails.testing.mixin.integration.Integration
@Integration
@IgnoreIf({ System.getProperty('TESTCONFIG') != 'misc' })
@@ -30,173 +29,173 @@ class DisableSpec extends AbstractHyphenatedSecuritySpec {
void 'lock account'() {
given:
- String username = 'admin'
+ def username = 'admin'
when:
- login username
+ login(username)
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'secure-annotated'
+ go('secure-annotated')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
then:
- 'false' == getUserProperty(username, 'accountLocked')
+ getUserProperty(username, 'accountLocked') == 'false'
when:
- setUserProperty username, 'accountLocked', true
+ setUserProperty(username, 'accountLocked', true)
then:
- 'true' == getUserProperty(username, 'accountLocked')
+ getUserProperty(username, 'accountLocked') == 'true'
when:
- login username
+ login(username)
then:
- pageSource.contains('accountLocked')
+ waitFor { pageSource.contains('accountLocked') }
// reset
when:
- setUserProperty username, 'accountLocked', false
+ setUserProperty(username, 'accountLocked', false)
then:
- 'false' == getUserProperty(username, 'accountLocked')
+ getUserProperty(username, 'accountLocked') == 'false'
}
void 'disable account'() {
given:
- String username = 'admin'
+ def username = 'admin'
when:
- login username
+ login(username)
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'secure-annotated'
+ go('secure-annotated')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
then:
- 'true' == getUserProperty(username, 'enabled')
+ getUserProperty(username, 'enabled') == 'true'
when:
- setUserProperty username, 'enabled', false
+ setUserProperty(username, 'enabled', false)
then:
- 'false' == getUserProperty(username, 'enabled')
+ getUserProperty(username, 'enabled') == 'false'
when:
- login username
+ login(username)
then:
- pageSource.contains('accountDisabled')
+ waitFor { pageSource.contains('accountDisabled') }
// reset
when:
- setUserProperty username, 'enabled', true
+ setUserProperty(username, 'enabled', true)
then:
- 'true' == getUserProperty(username, 'enabled')
+ getUserProperty(username, 'enabled') == 'true'
}
void 'expire account'() {
given:
- String username = 'admin'
+ def username = 'admin'
when:
- login username
+ login(username)
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'secure-annotated'
+ go('secure-annotated')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
then:
- 'false' == getUserProperty(username, 'accountExpired')
+ getUserProperty(username, 'accountExpired') == 'false'
when:
- setUserProperty username, 'accountExpired', true
+ setUserProperty(username, 'accountExpired', true)
then:
- 'true' == getUserProperty(username, 'accountExpired')
+ getUserProperty(username, 'accountExpired') == 'true'
when:
- login username
+ login(username)
then:
- pageSource.contains('accountExpired')
+ waitFor { pageSource.contains('accountExpired') }
// reset
when:
- setUserProperty username, 'accountExpired', false
+ setUserProperty(username, 'accountExpired', false)
then:
- 'false' == getUserProperty(username, 'accountExpired')
+ getUserProperty(username, 'accountExpired') == 'false'
}
void 'expire password'() {
given:
- String username = 'admin'
+ def username = 'admin'
when:
- login username
+ login(username)
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'secure-annotated'
+ go('secure-annotated')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
logout()
then:
- 'false' == getUserProperty(username, 'passwordExpired')
+ getUserProperty(username, 'passwordExpired') == 'false'
when:
- setUserProperty username, 'passwordExpired', true
+ setUserProperty(username, 'passwordExpired', true)
then:
- 'true' == getUserProperty(username, 'passwordExpired')
+ getUserProperty(username, 'passwordExpired') == 'true'
when:
- login username
+ login(username)
then:
- pageSource.contains('passwordExpired')
+ waitFor { pageSource.contains('passwordExpired') }
// reset
when:
- setUserProperty username, 'passwordExpired', false
+ setUserProperty(username, 'passwordExpired', false)
then:
- 'false' == getUserProperty(username, 'passwordExpired')
+ getUserProperty(username, 'passwordExpired') == 'false'
}
private void setUserProperty(String user, String propertyName, value) {
- go "hack/set-user-property?user=$user&$propertyName=$value"
+ go("hack/set-user-property?user=$user&$propertyName=$value")
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/HttpClientSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/HttpClientSpec.groovy
deleted file mode 100644
index 1a924198a..000000000
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/HttpClientSpec.groovy
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * 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 specs
-
-import io.micronaut.http.client.BlockingHttpClient
-import io.micronaut.http.client.HttpClient
-import spock.lang.Shared
-import spock.lang.Specification
-
-abstract class HttpClientSpec extends Specification {
-
- @Shared HttpClient _httpClient
- @Shared BlockingHttpClient _client
-
- HttpClient getHttpClient() {
- if(!_httpClient) {
- _httpClient = createHttpClient()
- }
- _httpClient
- }
-
- BlockingHttpClient getClient() {
- if(!_client) {
- _client = getHttpClient().toBlocking()
- }
- _client
- }
-
- HttpClient createHttpClient() {
- String baseUrl = "http://localhost:$serverPort"
- HttpClient.create(baseUrl.toURL())
- }
-
- def cleanupSpec() {
- resetHttpClient()
- }
-
- void resetHttpClient() {
- _httpClient?.close()
- _httpClient = null
- _client = null
- }
-}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/InheritanceSecuritySpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/InheritanceSecuritySpec.groovy
index 5d0898a5a..72e66346f 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/InheritanceSecuritySpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/InheritanceSecuritySpec.groovy
@@ -19,6 +19,8 @@
package specs
+import spock.lang.Unroll
+
import grails.testing.mixin.integration.Integration
import pages.IndexPage
import pages.LoginPage
@@ -33,6 +35,7 @@ class InheritanceSecuritySpec extends AbstractSecuritySpec {
go 'testData/addTestUsers'
}
+ @Unroll
void 'should redirect to login page for anonymous'() {
when:
go uri
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/MiscSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/MiscSpec.groovy
index 899f7b8c2..18f8e07ef 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/MiscSpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/MiscSpec.groovy
@@ -16,28 +16,27 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package specs
import geb.module.TextInput
-import grails.testing.mixin.integration.Integration
-import org.springframework.security.crypto.password.PasswordEncoder
import pages.IndexPage
import spock.lang.IgnoreIf
import spock.lang.Issue
+import grails.testing.mixin.integration.Integration
+
@Integration
@IgnoreIf({ System.getProperty('TESTCONFIG') != 'misc' })
class MiscSpec extends AbstractHyphenatedSecuritySpec {
void 'salted password'() {
given:
- String username = 'testuser_books_and_movies'
- PasswordEncoder passwordEncoder = createSha256Encoder()
+ def username = 'testuser_books_and_movies'
+ def passwordEncoder = createSha256Encoder()
when:
- String hashedPassword = getUserProperty(username, 'password')
- String notSalted = passwordEncoder.encode('password')
+ def hashedPassword = getUserProperty(username, 'password')
+ def notSalted = passwordEncoder.encode('password')
then:
notSalted != hashedPassword
@@ -45,44 +44,46 @@ class MiscSpec extends AbstractHyphenatedSecuritySpec {
void 'switch user'() {
when:
- login 'admin'
+ login('admin')
then:
- at IndexPage
+ at(IndexPage)
// verify logged in
when:
- go 'secure-annotated'
+ go('secure-annotated')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
- String auth = getSessionValue('SPRING_SECURITY_CONTEXT')
+ def auth = getSessionValue('SPRING_SECURITY_CONTEXT')
then:
- auth.contains 'Username=admin'
- auth.contains 'Authenticated=true'
- auth.contains 'ROLE_ADMIN'
- auth.contains 'ROLE_USER' // new, added since inferred from role hierarchy
- !auth.contains('ROLE_PREVIOUS_ADMINISTRATOR')
+ with(auth) {
+ contains('Username=admin')
+ contains('Authenticated=true')
+ contains('ROLE_ADMIN')
+ contains('ROLE_USER') // new, added since inferred from role hierarchy
+ !contains('ROLE_PREVIOUS_ADMINISTRATOR')
+ }
// switch via GET
when:
- go 'login/impersonate?username=testuser'
+ go('login/impersonate?username=testuser')
then:
- pageSource.contains('Error 404 Page Not Found')
+ waitFor { pageSource.contains('Error 404 Page Not Found') }
// switch via POST
when:
- go 'misc-test/test'
+ go('misc-test/test')
def input = $("#username").module(TextInput)
input.text = 'testuser'
$("#switchUserFormSubmitButton").click()
then:
- pageSource.contains('Available Controllers:')
+ waitFor { pageSource.contains('Available Controllers:') }
// verify logged in as testuser
@@ -90,370 +91,401 @@ class MiscSpec extends AbstractHyphenatedSecuritySpec {
auth = getSessionValue('SPRING_SECURITY_CONTEXT')
then:
- auth.contains 'Username=testuser'
- auth.contains 'Authenticated=true'
- auth.contains 'ROLE_USER'
- auth.contains 'ROLE_PREVIOUS_ADMINISTRATOR'
+ with(auth) {
+ contains('Username=testuser')
+ contains('Authenticated=true')
+ contains('ROLE_USER')
+ contains('ROLE_PREVIOUS_ADMINISTRATOR')
+ }
when:
- go 'secure-annotated/user-action'
+ go('secure-annotated/user-action')
then:
- pageSource.contains('you have ROLE_USER')
+ waitFor { pageSource.contains('you have ROLE_USER') }
// verify not logged in as admin
when:
- go 'secure-annotated/admin-either'
+ go('secure-annotated/admin-either')
then:
- pageSource.contains('Sorry, you\'re not authorized to view this page.')
+ waitFor { pageSource.contains('Sorry, you\'re not authorized to view this page.') }
// switch back via GET
when:
- go 'logout/impersonate'
+ go('logout/impersonate')
then:
- pageSource.contains('Error 404 Page Not Found')
+ waitFor { pageSource.contains('Error 404 Page Not Found') }
// switch via POST
when:
- go 'misc-test/test'
+ go('misc-test/test')
$("#exitUserFormSubmitButton").click()
then:
- pageSource.contains('Available Controllers:')
+ waitFor { pageSource.contains('Available Controllers:') }
// verify logged in as admin
when:
- go 'secure-annotated/admin-either'
+ go('secure-annotated/admin-either')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
auth = getSessionValue('SPRING_SECURITY_CONTEXT')
then:
- auth.contains 'Username=admin'
- auth.contains 'Authenticated=true'
- auth.contains 'ROLE_ADMIN'
- auth.contains 'ROLE_USER'
- !auth.contains('ROLE_PREVIOUS_ADMINISTRATOR')
+ with(auth) {
+ contains('Username=admin')
+ contains('Authenticated=true')
+ contains('ROLE_ADMIN')
+ contains('ROLE_USER')
+ !contains('ROLE_PREVIOUS_ADMINISTRATOR')
+ }
}
void 'hierarchical roles'() {
when:
- login 'admin'
+ login('admin')
then:
- at IndexPage
+ at(IndexPage)
// verify logged in
when:
- go 'secure-annotated'
+ go('secure-annotated')
then:
- pageSource.contains('you have ROLE_ADMIN')
+ waitFor { pageSource.contains('you have ROLE_ADMIN') }
when:
- String auth = getSessionValue('SPRING_SECURITY_CONTEXT')
+ def auth = getSessionValue('SPRING_SECURITY_CONTEXT')
then:
- auth.contains 'Authenticated=true'
- auth.contains 'ROLE_USER'
+ with(auth) {
+ contains('Authenticated=true')
+ contains('ROLE_USER')
+ }
// now get an action that's ROLE_USER only
when:
- go 'secure-annotated/user-action'
+ go('secure-annotated/user-action')
then:
- pageSource.contains('you have ROLE_USER')
+ waitFor { pageSource.contains('you have ROLE_USER') }
}
void 'taglibs unauthenticated'() {
when:
- go 'misc-test/test'
-
- then:
- !pageSource.contains('user and admin')
- !pageSource.contains('user and admin and foo')
- pageSource.contains('not user and not admin')
- !pageSource.contains('user or admin')
- pageSource.contains('accountNonExpired: "not logged in"')
- pageSource.contains('id: "not logged in"')
- pageSource.contains('Username is ""')
- !pageSource.contains('logged in true')
- pageSource.contains('logged in false')
- !pageSource.contains('switched true')
- pageSource.contains('switched false')
- pageSource.contains('switched original username ""')
-
- !pageSource.contains('access with role user: true')
- !pageSource.contains('access with role admin: true')
- pageSource.contains('access with role user: false')
- pageSource.contains('access with role admin: false')
-
- pageSource.contains('Can access /login/auth')
- !pageSource.contains('Can access /secure-annotated')
- !pageSource.contains('Cannot access /login/auth')
- pageSource.contains('Cannot access /secure-annotated')
-
- pageSource.contains('anonymous access: true')
- pageSource.contains('Can access /misc-test/test')
- !pageSource.contains('anonymous access: false')
- !pageSource.contains('Cannot access /misc-test/test')
+ go('misc-test/test')
+
+ then:
+ with(pageSource) {
+ !contains('user and admin')
+ !contains('user and admin and foo')
+ contains('not user and not admin')
+ !contains('user or admin')
+ contains('accountNonExpired: "not logged in"')
+ contains('id: "not logged in"')
+ contains('Username is ""')
+ !contains('logged in true')
+ contains('logged in false')
+ !contains('switched true')
+ contains('switched false')
+ contains('switched original username ""')
+
+ !contains('access with role user: true')
+ !contains('access with role admin: true')
+ contains('access with role user: false')
+ contains('access with role admin: false')
+
+ contains('Can access /login/auth')
+ !contains('Can access /secure-annotated')
+ !contains('Cannot access /login/auth')
+ contains('Cannot access /secure-annotated')
+
+ contains('anonymous access: true')
+ contains('Can access /misc-test/test')
+ !contains('anonymous access: false')
+ !contains('Cannot access /misc-test/test')
+ }
}
void 'taglibs user'() {
when:
- login 'testuser'
+ login('testuser')
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'misc-test/test'
+ go('misc-test/test')
then:
- !pageSource.contains('user and admin')
- !pageSource.contains('user and admin and foo')
- !pageSource.contains('not user and not admin')
- pageSource.contains('user or admin')
- pageSource.contains('accountNonExpired: "true"')
- !pageSource.contains('id: "not logged in"') // can't test on exact id, don't know what it is)
- pageSource.contains('Username is "testuser"')
- pageSource.contains('logged in true')
- !pageSource.contains('logged in false')
- !pageSource.contains('switched true')
- pageSource.contains('switched false')
- pageSource.contains('switched original username ""')
+ with(pageSource) {
+ !contains('user and admin')
+ !contains('user and admin and foo')
+ !contains('not user and not admin')
+ contains('user or admin')
+ contains('accountNonExpired: "true"')
+ !contains('id: "not logged in"') // can't test on exact id, don't know what it is)
+ contains('Username is "testuser"')
+ contains('logged in true')
+ !contains('logged in false')
+ !contains('switched true')
+ contains('switched false')
+ contains('switched original username ""')
- pageSource.contains('access with role user: true')
- !pageSource.contains('access with role admin: true')
- !pageSource.contains('access with role user: false')
- pageSource.contains('access with role admin: false')
+ contains('access with role user: true')
+ !contains('access with role admin: true')
+ !contains('access with role user: false')
+ contains('access with role admin: false')
- pageSource.contains('Can access /login/auth')
- !pageSource.contains('Can access /secure-annotated')
- !pageSource.contains('Cannot access /login/auth')
- pageSource.contains('Cannot access /secure-annotated')
+ contains('Can access /login/auth')
+ !contains('Can access /secure-annotated')
+ !contains('Cannot access /login/auth')
+ contains('Cannot access /secure-annotated')
- pageSource.contains('anonymous access: false')
- pageSource.contains('Can access /misc-test/test')
- !pageSource.contains('anonymous access: true')
+ contains('anonymous access: false')
+ contains('Can access /misc-test/test')
+ !contains('anonymous access: true')
+ }
}
void 'taglibs admin'() {
when:
- login 'admin'
+ login('admin')
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'misc-test/test'
+ go('misc-test/test')
then:
- pageSource.contains('user and admin')
- !pageSource.contains('user and admin and foo')
- !pageSource.contains('not user and not admin')
- pageSource.contains('user or admin')
- pageSource.contains('accountNonExpired: "true"')
- !pageSource.contains('id: "not logged in"') // can't test on exact id, don't know what it is)
- pageSource.contains('Username is "admin"')
+ with(pageSource) {
+ contains('user and admin')
+ !contains('user and admin and foo')
+ !contains('not user and not admin')
+ contains('user or admin')
+ contains('accountNonExpired: "true"')
+ !contains('id: "not logged in"') // can't test on exact id, don't know what it is)
+ contains('Username is "admin"')
- pageSource.contains('logged in true')
- !pageSource.contains('logged in false')
- !pageSource.contains('switched true')
- pageSource.contains('switched false')
- pageSource.contains('switched original username ""')
+ contains('logged in true')
+ !contains('logged in false')
+ !contains('switched true')
+ contains('switched false')
+ contains('switched original username ""')
- pageSource.contains('access with role user: true')
- pageSource.contains('access with role admin: true')
- !pageSource.contains('access with role user: false')
- !pageSource.contains('access with role admin: false')
+ contains('access with role user: true')
+ contains('access with role admin: true')
+ !contains('access with role user: false')
+ !contains('access with role admin: false')
- pageSource.contains('Can access /login/auth')
- pageSource.contains('Can access /secure-annotated')
- !pageSource.contains('Cannot access /login/auth')
- !pageSource.contains('Cannot access /secure-annotated')
+ contains('Can access /login/auth')
+ contains('Can access /secure-annotated')
+ !contains('Cannot access /login/auth')
+ !contains('Cannot access /secure-annotated')
- pageSource.contains('anonymous access: false')
- pageSource.contains('Can access /misc-test/test')
- !pageSource.contains('anonymous access: true')
- !pageSource.contains('Cannot access /misc-test/test')
+ contains('anonymous access: false')
+ contains('Can access /misc-test/test')
+ !contains('anonymous access: true')
+ !contains('Cannot access /misc-test/test')
+ }
}
void 'controller methods unauthenticated'() {
when:
- go 'misc-test/test-controller-methods'
+ go('misc-test/test-controller-methods')
then:
- pageSource.contains('getPrincipal: org.springframework.security.core.userdetails.User')
- pageSource.contains('Username=__grails.anonymous.user__')
- pageSource.contains('Granted Authorities=[ROLE_ANONYMOUS]')
- pageSource.contains('isLoggedIn: false')
- pageSource.contains('loggedIn: false')
- pageSource.contains('getAuthenticatedUser: null')
- pageSource.contains('authenticatedUser: null')
+ with(pageSource) {
+ contains('getPrincipal: org.springframework.security.core.userdetails.User')
+ contains('Username=__grails.anonymous.user__')
+ contains('Granted Authorities=[ROLE_ANONYMOUS]')
+ contains('isLoggedIn: false')
+ contains('loggedIn: false')
+ contains('getAuthenticatedUser: null')
+ contains('authenticatedUser: null')
+ }
}
void 'controller methods authenticated'() {
when:
- login 'admin'
+ login('admin')
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'misc-test/test-controller-methods'
+ go('misc-test/test-controller-methods')
then:
- pageSource.contains('getPrincipal: grails.plugin.springsecurity.userdetails.GrailsUser')
- pageSource.contains('principal: grails.plugin.springsecurity.userdetails.GrailsUser')
- pageSource.contains('Username=admin')
- pageSource.contains('isLoggedIn: true')
- pageSource.contains('loggedIn: true')
- pageSource.contains('getAuthenticatedUser: TestUser(username:admin)')
- pageSource.contains('authenticatedUser: TestUser(username:admin)')
+ with(pageSource) {
+ contains('getPrincipal: grails.plugin.springsecurity.userdetails.GrailsUser')
+ contains('principal: grails.plugin.springsecurity.userdetails.GrailsUser')
+ contains('Username=admin')
+ contains('isLoggedIn: true')
+ contains('loggedIn: true')
+ contains('getAuthenticatedUser: TestUser(username:admin)')
+ contains('authenticatedUser: TestUser(username:admin)')
+ }
}
void 'test hyphenated'() {
when:
- go 'foo-bar'
+ go('foo-bar')
then:
- pageSource.contains('Please Login')
+ waitFor { pageSource.contains('Please Login') }
when:
- go 'foo-bar/index'
+ to(IndexPage)
+
+ and:
+ go('foo-bar/index')
then:
- pageSource.contains('Please Login')
+ waitFor { pageSource.contains('Please Login') }
when:
- go 'foo-bar/bar-foo'
+ to(IndexPage)
+
+ and:
+ go('foo-bar/bar-foo')
then:
- pageSource.contains('Please Login')
+ waitFor { pageSource.contains('Please Login') }
when:
logout()
- login 'admin'
then:
- at IndexPage
+ at(IndexPage)
+
+ when:
+ login('admin')
+
+ then:
+ at(IndexPage)
when:
- go 'foo-bar'
+ go('foo-bar')
then:
- pageSource.contains('INDEX')
+ waitFor { pageSource.contains('INDEX') }
when:
- go 'foo-bar/index'
+ go('foo-bar/index')
then:
- pageSource.contains('INDEX')
+ waitFor { pageSource.contains('INDEX') }
when:
- go 'foo-bar/bar-foo'
+ go('foo-bar/bar-foo')
then:
- pageSource.contains('barFoo')
+ waitFor { pageSource.contains('barFoo') }
}
@Issue('https://github.com/apache/grails-spring-security/issues/414')
void 'test Servlet API methods unauthenticated'() {
when:
- go 'misc-test/test-servlet-api-methods'
+ go('misc-test/test-servlet-api-methods')
then:
- pageSource.contains('request.getUserPrincipal(): null')
- pageSource.contains('request.userPrincipal: null')
- pageSource.contains('request.isUserInRole(\'ROLE_ADMIN\'): false')
- pageSource.contains('request.isUserInRole(\'ROLE_FOO\'): false')
- pageSource.contains('request.getRemoteUser(): null')
- pageSource.contains('request.remoteUser: null')
+ with(pageSource) {
+ contains('request.getUserPrincipal(): null')
+ contains('request.userPrincipal: null')
+ contains('request.isUserInRole(\'ROLE_ADMIN\'): false')
+ contains('request.isUserInRole(\'ROLE_FOO\'): false')
+ contains('request.getRemoteUser(): null')
+ contains('request.remoteUser: null')
+ }
}
@Issue('https://github.com/apache/grails-spring-security/issues/414')
void 'test Servlet API methods authenticated'() {
when:
- login 'admin'
+ login('admin')
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'misc-test/test-servlet-api-methods'
+ go('misc-test/test-servlet-api-methods')
then:
- pageSource.contains('request.getUserPrincipal(): UsernamePasswordAuthenticationToken')
- pageSource.contains('request.userPrincipal: UsernamePasswordAuthenticationToken')
- pageSource.contains('request.isUserInRole(\'ROLE_ADMIN\'): true')
- pageSource.contains('request.isUserInRole(\'ROLE_FOO\'): false')
- pageSource.contains('request.getRemoteUser(): admin')
- pageSource.contains('request.remoteUser: admin')
+ with(pageSource) {
+ contains('request.getUserPrincipal(): UsernamePasswordAuthenticationToken')
+ contains('request.userPrincipal: UsernamePasswordAuthenticationToken')
+ contains('request.isUserInRole(\'ROLE_ADMIN\'): true')
+ contains('request.isUserInRole(\'ROLE_FOO\'): false')
+ contains('request.getRemoteUser(): admin')
+ contains('request.remoteUser: admin')
+ }
}
@Issue('https://github.com/apache/grails-spring-security/issues/403')
void 'test controller with annotated index action, unauthenticated'() {
when:
- go 'index-annotated'
+ go('index-annotated')
then:
- pageSource.contains('Please Login')
+ waitFor { pageSource.contains('Please Login') }
when:
- go 'index-annotated/'
+ go('index-annotated/')
then:
- pageSource.contains('Please Login')
+ waitFor { pageSource.contains('Please Login') }
when:
- go 'index-annotated/index'
+ go('index-annotated/index')
then:
- pageSource.contains('Please Login')
+ waitFor { pageSource.contains('Please Login') }
when:
- go 'index-annotated/show'
+ go('index-annotated/show')
then:
- pageSource.contains('Please Login')
+ waitFor { pageSource.contains('Please Login') }
}
@Issue('https://github.com/apache/grails-spring-security/issues/403')
void 'test controller with annotated index action, authenticated'() {
when:
- login 'admin'
+ login('admin')
then:
- at IndexPage
+ at(IndexPage)
when:
- go 'index-annotated'
+ go('index-annotated')
then:
- pageSource.contains('index action, principal: ')
+ waitFor { pageSource.contains('index action, principal: ') }
when:
- go 'index-annotated/'
+ go('index-annotated/')
then:
- pageSource.contains('index action, principal: ')
+ waitFor { pageSource.contains('index action, principal: ') }
when:
- go 'index-annotated/index'
+ go('index-annotated/index')
then:
- pageSource.contains('index action, principal: ')
+ waitFor { pageSource.contains('index action, principal: ') }
when:
- go 'index-annotated/show'
+ go('index-annotated/show')
then:
- pageSource.contains('Sorry, you\'re not authorized to view this page.')
+ waitFor { pageSource.contains('Sorry, you\'re not authorized to view this page.') }
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RequestmapSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RequestmapSpec.groovy
index 01b19f066..e676397ae 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RequestmapSpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RequestmapSpec.groovy
@@ -20,6 +20,8 @@
package specs
import com.testapp.TestDataService
+import spock.lang.Stepwise
+
import grails.testing.mixin.integration.Integration
import pages.requestmap.CreateRequestmapPage
import pages.requestmap.EditRequestmapPage
@@ -27,100 +29,82 @@ import pages.requestmap.ListRequestmapPage
import pages.requestmap.ShowRequestmapPage
import spock.lang.IgnoreIf
+@Stepwise
@Integration
@IgnoreIf({ System.getProperty('TESTCONFIG') != 'requestmap' })
class RequestmapSpec extends AbstractSecuritySpec {
void 'test request maps are initially present'() {
when:
- go 'testRequestmap/list?max=100'
+ def page = to(ListRequestmapPage, max: 100)
then:
- at ListRequestmapPage
- requestmapRows.size() == TestDataService.URIS_FOR_REQUESTMAPS.size()
+ page.requestmapRows.size() == TestDataService.URIS_FOR_REQUESTMAPS.size()
}
void 'add a requestmap'() {
when:
- to ListRequestmapPage
- newRequestmapButton.click()
-
- then:
- at CreateRequestmapPage
+ to(ListRequestmapPage).with {
+ newRequestmapButton.click()
+ }
- when:
- $('form').url = '/nuevo/**'
- configAttribute = 'ROLE_ADMIN'
- createButton.click()
+ and:
+ def page = at(CreateRequestmapPage)
+ page.urlField.text = '/nuevo/**'
+ page.configAttributeField.text = 'ROLE_ADMIN'
+ page.createButton.click()
+ page = at(ShowRequestmapPage)
then:
- at ShowRequestmapPage
- value('URL') == '/nuevo/**'
- configAttribute == 'ROLE_ADMIN'
+ page.value('URL') == '/nuevo/**'
+ page.configAttribute == 'ROLE_ADMIN'
when:
- go 'testRequestmap/list?max=100'
+ page = to(ListRequestmapPage, max: 100)
then:
- at ListRequestmapPage
- requestmapRows.size() == (TestDataService.URIS_FOR_REQUESTMAPS.size() + 1)
+ page.requestmapRows.size() == (TestDataService.URIS_FOR_REQUESTMAPS.size() + 1)
}
void 'edit the details'() {
when:
- go 'testRequestmap/list?max=100'
+ def page = to(ListRequestmapPage, max: 100)
+ page.requestmapRow(19).showLink.click()
+ page = at(ShowRequestmapPage)
- then:
- at ListRequestmapPage
+ and:
+ page.editButton.click()
+ page = at(EditRequestmapPage)
- when:
- requestmapRow(19).showLink.click()
+ and:
+ page.urlField.text = '/secure2/**'
+ page.configAttributeField.text = 'ROLE_ADMINX'
+ page.updateButton.click()
+ page = at(ShowRequestmapPage)
then:
- at ShowRequestmapPage
-
- when:
- editButton.click()
-
- then:
- at EditRequestmapPage
-
- when:
- $('form').url = '/secure2/**'
- configAttribute = 'ROLE_ADMINX'
- updateButton.click()
-
- then:
- at ShowRequestmapPage
- value('URL') == '/secure2/**'
- configAttribute == 'ROLE_ADMINX'
+ page.value('URL') == '/secure2/**'
+ page.configAttribute == 'ROLE_ADMINX'
}
void 'delete requestmap'() {
when:
- go 'testRequestmap/list?max=100'
-
- then:
- at ListRequestmapPage
-
- when:
- requestmapRow(19).showLink.click()
+ def page = to(ListRequestmapPage, max: 100)
+ page.requestmapRow(19).showLink.click()
+ page = at(ShowRequestmapPage)
+ def deletedId = page.id
- then:
- at ShowRequestmapPage
-
- when:
- def deletedId = id
- withConfirm { deleteButton.click() }
+ and:
+ withConfirm { page.deleteButton.click() }
+ page = at(ListRequestmapPage)
then:
- at ListRequestmapPage
- message == "TestRequestmap $deletedId deleted"
+ page.message == "TestRequestmap $deletedId deleted"
when:
- go 'testRequestmap/list?max=100'
+ page = to(ListRequestmapPage, max: 100)
then:
- requestmapRows.size() == TestDataService.URIS_FOR_REQUESTMAPS.size()
+ page.requestmapRows.size() == TestDataService.URIS_FOR_REQUESTMAPS.size()
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RoleSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RoleSpec.groovy
index 96ffcdcc4..0074a4171 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RoleSpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/RoleSpec.groovy
@@ -16,16 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package specs
-import grails.testing.mixin.integration.Integration
import pages.role.CreateRolePage
import pages.role.EditRolePage
import pages.role.ListRolePage
import pages.role.ShowRolePage
import spock.lang.IgnoreIf
+import spock.lang.Stepwise
+import grails.testing.mixin.integration.Integration
+
+@Stepwise
@Integration
@IgnoreIf({ !(
System.getProperty('TESTCONFIG') == 'annotation' ||
@@ -38,85 +40,81 @@ class RoleSpec extends AbstractSecuritySpec {
void 'there are no roles initially'() {
when:
- to ListRolePage
+ def page = to(ListRolePage)
then:
- roleRows.size() == 0
+ page.roleRows.size() == 0
}
void 'add a role'() {
when:
- to ListRolePage
- newRoleButton.click()
+ def page = to(ListRolePage)
+ page.newRoleButton.click()
- then:
- at CreateRolePage
+ and:
+ page = at(CreateRolePage)
+ page.authorityField.text = 'test'
+ page.createButton.click()
- when:
- authority = 'test'
- createButton.click()
+ and:
+ page = at(ShowRolePage)
then:
- at ShowRolePage
- authority == 'test'
+ page.authority == 'test'
}
void 'edit the details'() {
when:
- to ListRolePage
- roleRow(0).showLink.click()
+ def page = to(ListRolePage)
+ page.roleRow(0).showLink.click()
+ page = at(ShowRolePage)
- then:
- at ShowRolePage
+ and:
+ page.editButton.click()
+ page = at(EditRolePage)
- when:
- editButton.click()
+ and:
+ page.authorityField.text = 'test_new'
+ page.updateButton.click()
then:
- at EditRolePage
+ at(ShowRolePage)
when:
- authority = 'test_new'
- updateButton.click()
+ page = to(ListRolePage)
then:
- at ShowRolePage
-
- when:
- to ListRolePage
-
- then:
- roleRows.size() == 1
-
- def row = roleRow(0)
- row.authority == 'test_new'
+ //page.roleRows.size() == 1
+ page.roleRow(0).authority == 'test_new'
}
void 'show role'() {
when:
- to ListRolePage
- roleRow(0).showLink.click()
+ def page = to(ListRolePage)
+ page.roleRow(0).showLink.click()
then:
- at ShowRolePage
+ at(ShowRolePage)
}
void 'delete role'() {
when:
- to ListRolePage
- roleRow(0).showLink.click()
- def deletedId = id
+ def page = to(ListRolePage).tap {
+ roleRow(0).showLink.click()
+ }
+ def deletedId = page.id
- then:
- at ShowRolePage
+ and:
+ page = at(ShowRolePage)
- when:
- withConfirm { deleteButton.click() }
+ and:
+ withConfirm { page.deleteButton.click() }
- then:
- at ListRolePage
+ and:
+ page = at(ListRolePage)
- message == "TestRole $deletedId deleted"
- roleRows.size() == 0
+ then:
+ page.message == "TestRole $deletedId deleted"
+ page.roleRows.size() == 0
}
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/TestFormParamsControllerSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/TestFormParamsControllerSpec.groovy
index 9af278c38..d6096abdc 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/TestFormParamsControllerSpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/TestFormParamsControllerSpec.groovy
@@ -16,165 +16,190 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package specs
-import functional.test.app.Application
-import grails.testing.mixin.integration.Integration
-import io.micronaut.http.HttpRequest
-import io.micronaut.http.HttpResponse
-import io.micronaut.http.HttpStatus
-import io.micronaut.http.uri.UriTemplate
-import org.springframework.util.LinkedMultiValueMap
-import org.springframework.util.MultiValueMap
+import java.net.http.HttpRequest
+import java.nio.charset.StandardCharsets
+
import spock.lang.IgnoreIf
import spock.lang.Issue
import spock.lang.Shared
+import spock.lang.Specification
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+@Integration
@IgnoreIf({ System.getProperty('TESTCONFIG') != 'putWithParams' })
@Issue('https://github.com/apache/grails-spring-security/issues/554')
-@Integration(applicationClass = Application)
-class TestFormParamsControllerSpec extends HttpClientSpec {
+class TestFormParamsControllerSpec extends Specification implements HttpClientSupport {
- @Shared String USERNAME = "Admin"
- @Shared String PASSWORD = "myPassword"
+ @Shared String USERNAME = 'Admin'
+ @Shared String PASSWORD = 'myPassword'
void 'PUT request with no parameters'() {
-
- when: "A PUT request with no parameters is made"
- HttpResponse response = client.exchange(HttpRequest.PUT("/testFormParams/permitAll", "").contentType("application/x-www-form-urlencoded"), String)
-
- then: "the controller responds with the correct status and parameters are null"
- response.status == HttpStatus.OK
- response.body() == "username: null, password: null"
+ when: 'A PUT request with no parameters is made'
+ def response = httpPut(
+ '/testFormParams/permitAll',
+ '',
+ 'application/x-www-form-urlencoded'
+ )
+
+ then: 'the controller responds with the correct status and parameters are null'
+ response.assertEquals(200, 'username: null, password: null')
}
void 'PUT request with parameters in the URL'() {
- when: "A PUT request with no parameters is made"
- String expandUrl = new UriTemplate("/testFormParams/permitAll{?username,password}").expand(["username": USERNAME, "password": PASSWORD])
- HttpResponse response = client.exchange(HttpRequest.PUT(expandUrl, "").contentType("application/x-www-form-urlencoded"), String)
-
- then: "the controller responds with the correct status and parameters are extracted"
- response.status == HttpStatus.OK
- response.body() == "username: ${USERNAME}, password: ${PASSWORD}"
+ when: 'A PUT request with parameters is made'
+ def response = httpPut(
+ urlWithParams('/testFormParams/permitAll', username: USERNAME, password: PASSWORD),
+ '',
+ 'application/x-www-form-urlencoded'
+ )
+
+ then: 'the controller responds with the correct status and parameters are extracted'
+ response.assertEquals(200, "username: $USERNAME, password: $PASSWORD")
}
void 'PUT request with parameters as x-www-form-urlencoded'() {
- given: "a form with username and password params"
- MultiValueMap form = new LinkedMultiValueMap()
- form.add("username", USERNAME)
- form.add("password", PASSWORD)
-
- when: "A PUT request with form params is made"
- HttpResponse response = client.exchange(HttpRequest.PUT("/testFormParams/permitAll", form).contentType("application/x-www-form-urlencoded"), String)
-
- then: "the controller responds with the correct status and parameters are extracted"
- response.status == HttpStatus.OK
- response.body() == "username: ${USERNAME}, password: ${PASSWORD}"
+ when: 'A PUT request with form params is made'
+ def request = newHttpRequestWith('/testFormParams/permitAll') {
+ method('PUT', formBodyWith(username: USERNAME, password: PASSWORD))
+ header('Content-Type', 'application/x-www-form-urlencoded')
+ }
+ def response = sendHttpRequest(request)
+
+ then: 'the controller responds with the correct status and parameters are extracted'
+ response.assertEquals(200, "username: $USERNAME, password: $PASSWORD")
}
void 'PUT request with NULL Content-Type and parameters in the URL'() {
- when: "A PUT request with no parameters is made"
- String expandUrl = new UriTemplate("/testFormParams/permitAll{?username,password}").expand(["username": USERNAME, "password": PASSWORD])
- HttpResponse response = client.exchange(HttpRequest.PUT(expandUrl, ""), String)
-
- then: "the controller responds with the correct status and parameters are extracted"
- response.status == HttpStatus.OK
- response.body() == "username: ${USERNAME}, password: ${PASSWORD}"
+ when: 'A PUT request with no parameters is made'
+ def url = urlWithParams('/testFormParams/permitAll', username: USERNAME, password: PASSWORD)
+ def request = newHttpRequestWith(url) {
+ method('PUT', HttpRequest.BodyPublishers.ofString(''))
+ }
+ def response = sendHttpRequest(request)
+
+ then: 'the controller responds with the correct status and parameters are extracted'
+ response.assertEquals(200, "username: $USERNAME, password: $PASSWORD")
}
void 'PUT request with NULL Content-Type'() {
- when: "A PUT request with NULL Content-Type is made"
- HttpResponse response = client.exchange(HttpRequest.PUT("/testFormParams/permitAll", ""), String)
+ when: 'A PUT request with NULL Content-Type is made'
+ def request = newHttpRequestWith('/testFormParams/permitAll') {
+ method('PUT', HttpRequest.BodyPublishers.noBody())
+ }
+ def response = sendHttpRequest(request)
then: "the controller responds with the correct status and parameters are null"
- response.status == HttpStatus.OK
- response.body() == "username: null, password: null"
+ response.assertEquals(200, 'username: null, password: null')
}
void 'PATCH request with no parameters'() {
- when: "A PATCH request with no parameters is made"
- HttpResponse response = client.exchange(HttpRequest.PATCH("/testFormParams/permitAll", "").contentType("application/x-www-form-urlencoded"), String)
-
- then: "the controller responds with the correct status and parameters are null"
- response.status == HttpStatus.OK
- response.body() == "username: null, password: null"
+ when: 'A PATCH request with no parameters is made'
+ def response = httpPatch(
+ '/testFormParams/permitAll',
+ '',
+ 'application/x-www-form-urlencoded'
+ )
+
+ then: 'the controller responds with the correct status and parameters are null'
+ response.assertEquals(200, 'username: null, password: null')
}
void 'PATCH request with parameters in the URL'() {
when:
- String expandUrl = new UriTemplate("/testFormParams/permitAll{?username,password}").expand(["username": USERNAME, "password": PASSWORD])
- HttpResponse response = client.exchange(HttpRequest.PATCH(expandUrl, "").contentType("application/x-www-form-urlencoded"), String)
-
- then: "the controller responds with the correct status and parameters are extracted"
- response.status == HttpStatus.OK
- response.body() == "username: ${USERNAME}, password: ${PASSWORD}"
+ def response = httpPatch(
+ urlWithParams('/testFormParams/permitAll', username: USERNAME, password: PASSWORD),
+ '',
+ 'application/x-www-form-urlencoded'
+ )
+
+ then: 'the controller responds with the correct status and parameters are extracted'
+ response.assertEquals(200, "username: $USERNAME, password: $PASSWORD")
}
void 'PATCH request with parameters as x-www-form-urlencoded'() {
- given: "a form with username and password params"
- MultiValueMap form = new LinkedMultiValueMap()
- form.add("username", USERNAME)
- form.add("password", PASSWORD)
-
- when: "A PATCH request with form params is made"
- HttpResponse response = client.exchange(HttpRequest.PATCH("/testFormParams/permitAll", form
- ).contentType("application/x-www-form-urlencoded"), String)
-
- then: "the controller responds with the correct status and parameters are extracted"
- response.status == HttpStatus.OK
- response.body() == "username: ${USERNAME}, password: ${PASSWORD}"
+ when: 'A PATCH request with form params is made'
+ def request = newHttpRequestWith('/testFormParams/permitAll') {
+ method('PATCH', formBodyWith(username: USERNAME, password: PASSWORD))
+ header('Content-Type', 'application/x-www-form-urlencoded')
+ }
+ def response = sendHttpRequest(request)
+
+ then: 'the controller responds with the correct status and parameters are extracted'
+ response.assertEquals(200, "username: $USERNAME, password: $PASSWORD")
}
void 'PUT request to secured endpoint with parameters as x-www-form-urlencoded'() {
- given: "a form with username and password params"
- MultiValueMap form = new LinkedMultiValueMap()
- form.add("username", USERNAME)
- form.add("password", PASSWORD)
-
- when: "A PUT request with form params is made to a secured endpoint"
- HttpResponse response = client.exchange(HttpRequest.PUT("/testFormParams/permitAdmin", form
- ).contentType("application/x-www-form-urlencoded"), String)
-
- then: "the request is not processed by the controller"
- response.status == HttpStatus.OK // MN Client isn't exposing this is a 302 to login
- response.body() != "username: ${USERNAME}, password: ${PASSWORD}"
+ when: 'A PUT request with form params is made to a secured endpoint'
+ def request = newHttpRequestWith('/testFormParams/permitAdmin') {
+ method('PUT', formBodyWith(username: USERNAME, password: PASSWORD))
+ header('Content-Type', 'application/x-www-form-urlencoded')
+ }
+ def response = sendHttpRequest(request)
+
+ then: 'the request is not processed by the controller'
+ response.assertNotEquals(200, "username: $USERNAME, password: $PASSWORD") // Client redirects to login page
}
void 'PATCH request to secured endpoint with parameters as x-www-form-urlencoded'() {
- given:
- MultiValueMap form = new LinkedMultiValueMap()
- form.add("username", USERNAME)
- form.add("password", PASSWORD)
-
- when: "A PATCH request with form params is made to a secured endpoint"
- HttpResponse response = client.exchange(HttpRequest.PATCH("/testFormParams/permitAdmin", form
- ).contentType("application/x-www-form-urlencoded"), String)
-
- then: "the request is not processed by the controller"
- response.status == HttpStatus.OK // MN Client isn't exposing this is a 302 to login
- response.body() != "username: ${USERNAME}, password: ${PASSWORD}"
+ when: 'A PATCH request with form params is made to a secured endpoint'
+ def request = newHttpRequestWith('/testFormParams/permitAdmin') {
+ method('PATCH', formBodyWith(username: USERNAME, password: PASSWORD))
+ header('Content-Type', 'application/x-www-form-urlencoded')
+ }
+ def response = sendHttpRequest(request)
+
+ then: 'the request is not processed by the controller'
+ response.assertNotEquals(200, "username: $USERNAME, password: $PASSWORD") // Client redirects to login page
}
void 'PATCH request with NULL Content-Type and parameters in the URL'() {
when:
- String expandUrl = new UriTemplate("/testFormParams/permitAll{?username,password}").expand(["username": USERNAME, "password": PASSWORD])
- HttpResponse response = client.exchange(HttpRequest.PATCH(expandUrl, ""
- ).contentType("application/x-www-form-urlencoded"), String)
-
- then: "the controller responds with the correct status and parameters are extracted"
- response.status == HttpStatus.OK
- response.body() == "username: ${USERNAME}, password: ${PASSWORD}"
+ def url = urlWithParams('/testFormParams/permitAll', username: USERNAME, password: PASSWORD)
+ def request = newHttpRequestWith(url) {
+ method('PATCH', HttpRequest.BodyPublishers.noBody())
+ }
+ def response = sendHttpRequest(request)
+
+ then: 'the controller responds with the correct status and parameters are extracted'
+ response.assertEquals(200, "username: $USERNAME, password: $PASSWORD")
}
void 'PATCH request with NULL Content-Type'() {
- when: "A PATCH request with NULL Content-Type is made"
- HttpResponse response = client.exchange(HttpRequest.PATCH("/testFormParams/permitAll", ""
- ), String)
+ when: 'A PATCH request with NULL Content-Type is made'
+ def request = newHttpRequestWith('/testFormParams/permitAll') {
+ method('PATCH', HttpRequest.BodyPublishers.noBody())
+ }
+ def response = sendHttpRequest(request)
+
+ then: 'the controller responds with the correct status and parameters are null'
+ response.assertEquals(200, 'username: null, password: null')
+ }
- then: "the controller responds with the correct status and parameters are null"
- response.status == HttpStatus.OK
- response.body() == "username: null, password: null"
+ static String urlWithParams(Map params, String url) {
+ if (!params) {
+ return url
+ }
+ def queryString = params.collect { key, value ->
+ "${encode(key)}=${encode(value)}"
+ }.join('&')
+ "$url${url.contains('?') ? '&' : '?'}$queryString"
+ }
+
+ private static HttpRequest.BodyPublisher formBodyWith(Map form) {
+ HttpRequest.BodyPublishers.ofString(toFormUrlEncoded(form))
}
+ private static String toFormUrlEncoded(Map form) {
+ form.collect { key, value ->
+ "${encode(key)}=${encode(value)}"
+ }.join('&')
+ }
+
+ private static String encode(String value) {
+ URLEncoder.encode(value, StandardCharsets.UTF_8)
+ }
}
diff --git a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/UserSpec.groovy b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/UserSpec.groovy
index 30b2c499d..eafb922f9 100644
--- a/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/UserSpec.groovy
+++ b/plugin-core/examples/functional-test-app/src/integration-test/groovy/specs/UserSpec.groovy
@@ -16,114 +16,105 @@
* specific language governing permissions and limitations
* under the License.
*/
-
package specs
-import grails.testing.mixin.integration.Integration
import pages.user.CreateUserPage
import pages.user.EditUserPage
import pages.user.ListUserPage
import pages.user.ShowUserPage
import spock.lang.IgnoreIf
+import spock.lang.Stepwise
+import grails.testing.mixin.integration.Integration
+
+@Stepwise
@Integration
@IgnoreIf({ !(
System.getProperty('TESTCONFIG') == 'annotation' ||
- System.getProperty('TESTCONFIG') == 'basic' ||
- System.getProperty('TESTCONFIG') == 'basicCacheUsers' ||
- System.getProperty('TESTCONFIG') == 'requestmap' ||
- System.getProperty('TESTCONFIG') == 'static')
+ System.getProperty('TESTCONFIG') == 'basic' ||
+ System.getProperty('TESTCONFIG') == 'basicCacheUsers' ||
+ System.getProperty('TESTCONFIG') == 'requestmap' ||
+ System.getProperty('TESTCONFIG') == 'static')
})
class UserSpec extends AbstractSecuritySpec {
void 'there are no users initially'() {
when:
- to ListUserPage
+ def page = to(ListUserPage)
then:
- userRows.size() == 0
+ page.userRows.size() == 0
}
void 'add a user'() {
when:
- to ListUserPage
- newUserButton.click()
-
- then:
- at CreateUserPage
+ def page = to(ListUserPage)
+ page.newUserButton.click()
- when:
- username = 'new_user'
- password = 'p4ssw0rd'
- $('#enabled').click()
- createButton.click()
+ and:
+ page = at(CreateUserPage)
+ page.usernameField.text = 'new_user'
+ page.passwordField.text = 'p4ssw0rd'
+ page.enabledCheckbox.check()
+ page.createButton.click()
+ page = at(ShowUserPage)
then:
- at ShowUserPage
- username == 'new_user'
- userEnabled == true
+ page.username == 'new_user'
+ page.userEnabled == true
}
void 'edit the details'() {
when:
- to ListUserPage
- userRow(0).showLink.click()
+ def page = to(ListUserPage)
+ page.userRow(0).showLink.click()
- then:
- at ShowUserPage
+ and:
+ page = at(ShowUserPage)
+ page.editButton.click()
+ page = at(EditUserPage)
- when:
- editButton.click()
+ and:
+ page.usernameField.text = 'new_user2'
+ page.passwordField.text = 'p4ssw0rd2'
+ page.enabledCheckbox.uncheck()
+ page.updateButton.click()
then:
- at EditUserPage
+ at(ShowUserPage)
when:
- username = 'new_user2'
- password = 'p4ssw0rd2'
- $('#enabled').click()
-
- updateButton.click()
+ page = to(ListUserPage)
then:
- at ShowUserPage
-
- when:
- to ListUserPage
-
- then:
- userRows.size() == 1
-
- def row = userRow(0)
+ page.userRows.size() == 1
+ def row = page.userRow(0)
row.username == 'new_user2'
!row.userEnabled
}
void 'show user'() {
when:
- to ListUserPage
- userRow(0).showLink.click()
+ def page = to(ListUserPage)
+ page.userRow(0).showLink.click()
then:
- at ShowUserPage
+ at(ShowUserPage)
}
void 'delete user'() {
when:
- to ListUserPage
- userRow(0).showLink.click()
- def deletedId = id
+ def page = to(ListUserPage)
+ page.userRow(0).showLink.click()
+ def deletedId = page.id
+ page = at(ShowUserPage)
- then:
- at ShowUserPage
-
- when:
- withConfirm { deleteButton.click() }
+ and:
+ withConfirm { page.deleteButton.click() }
+ page = at(ListUserPage)
then:
- at ListUserPage
-
- message == "TestUser $deletedId deleted."
- userRows.size() == 0
+ page.message == "TestUser $deletedId deleted."
+ page.userRows.size() == 0
}
}
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
index 22a0ad2ca..b77633e1d 100644
--- 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
@@ -21,7 +21,8 @@ package grails.plugin.springsecurity
import spock.lang.Specification
import org.springframework.beans.factory.annotation.Autowired
-import org.springframework.context.ApplicationContext
+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
@@ -30,54 +31,51 @@ import grails.testing.mixin.integration.Integration
@Integration
class SecurityAutoConfigurationExcluderIntegrationSpec extends Specification {
- @Autowired
- ApplicationContext applicationContext
+ @Autowired
+ ConfigurableApplicationContext applicationContext
- void "SecurityAutoConfigurationExcluder class is on the classpath"() {
- expect:
- Class.forName(
- 'grails.plugin.springsecurity.SecurityAutoConfigurationExcluder'
- )
- }
+ void "SecurityAutoConfigurationExcluder class is on the classpath"() {
+ expect:
+ Class.forName(
+ 'grails.plugin.springsecurity.SecurityAutoConfigurationExcluder'
+ )
+ }
- void "SecurityAutoConfiguration bean is not registered"() {
- given:
- def secAutoConfig = Class.forName(
- 'org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration'
- )
+ void "no Spring Boot SecurityFilterChain bean is registered alongside the plugin"() {
+ given: 'the application context bean factory'
+ ConfigurableListableBeanFactory beanFactory = applicationContext.beanFactory
- expect:
- applicationContext
- .getBeanNamesForType(secAutoConfig).length == 0
- }
+ and: 'all SecurityFilterChain beans visible to the application context'
+ def filterChainBeans = applicationContext.getBeanNamesForType(SecurityFilterChain)
- void "SecurityFilterAutoConfiguration bean is not registered"() {
- given:
- def secFilterAutoConfig = Class.forName(
- 'org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration'
- )
+ expect: 'none come from Spring Boot security auto-configurations'
+ filterChainBeans.every { name ->
+ !beanFactory.getBeanDefinition(name).beanClassName?.startsWith('org.springframework.boot.security.')
+ }
- expect:
- applicationContext
- .getBeanNamesForType(secFilterAutoConfig).length == 0
- }
+ 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 duplicate SecurityFilterChain beans from auto-configuration"() {
- given:
- def filterChainBeans = applicationContext
- .getBeanNamesForType(SecurityFilterChain)
+ void "no Spring Boot in-memory UserDetailsService is registered alongside the plugin"() {
+ given: 'the application context bean factory'
+ ConfigurableListableBeanFactory beanFactory = applicationContext.beanFactory
- expect:
- filterChainBeans.length <= 1
- }
+ and: 'all UserDetailsService beans visible to the application context'
+ def udsBeans = applicationContext.getBeanNamesForType(UserDetailsService)
- void "only the plugin UserDetailsService is registered"() {
- given:
- def udsBeans = applicationContext
- .getBeanNamesForType(UserDetailsService)
+ expect: 'at least one (the plugin one) exists'
+ udsBeans.length >= 1
- expect:
- udsBeans.length >= 1
- udsBeans.any { it.contains('userDetailsService') }
- }
+ 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 0dad526dd..38f427f1e 100644
--- a/plugin-core/plugin/build.gradle
+++ b/plugin-core/plugin/build.gradle
@@ -43,6 +43,7 @@ dependencies {
implementation platform("org.apache.grails:grails-bom:$grailsVersion")
+ api project(':spring-security-compat')
api 'org.apache.grails:grails-async' // AsyncGrailsWebRequest is used in public API
api 'org.apache.grails:grails-mimetypes'
api 'org.apache.grails.data:grails-datastore-core' // API because used in templates
@@ -74,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
new file mode 100644
index 000000000..bc810630c
--- /dev/null
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy
@@ -0,0 +1,64 @@
+/*
+ * 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 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.:
When the Grails Spring Security plugin is on the classpath, Spring Boot's
- * security auto-configurations (e.g. {@code SecurityAutoConfiguration},
- * {@code SecurityFilterAutoConfiguration}) create duplicate
- * {@code SecurityFilterChain} beans and other security infrastructure that
- * conflicts with the plugin's own bean definitions in
- * {@link SpringSecurityCoreGrailsPlugin#doWithSpring}.
+ *
Why this filter exists
*
- *
Previously, users had to manually exclude up to 7 auto-configuration classes
- * in {@code application.yml}. This filter removes that requirement by
- * automatically filtering them out during Spring Boot's auto-configuration
- * discovery phase.
+ *
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:
+ *
+ *
+ *
{@code @Bean SecurityFilterChain} - user-defined
+ * {@code SecurityFilterChain} beans are auto-merged
+ * into the plugin's {@code FilterChainProxy}. User chains are prepended
+ * (higher precedence) so their typically more-specific request matchers
+ * win against the plugin's catch-all chain. Disable via
+ * {@code grails.plugin.springsecurity.componentBased.autoMergeSecurityFilterChain: false}.
+ *
{@code @Bean WebSecurityCustomizer} - still a no-op.
+ * The plugin does not use Spring's {@code WebSecurity} builder. To
+ * exclude URLs from security checks, use
+ * {@code grails.plugin.springsecurity.ipRestrictions} or
+ * {@code grails.plugin.springsecurity.staticRules} with
+ * {@code permitAll} access.
+ *
{@code @Bean AuthenticationManager} - the plugin
+ * registers an {@code authenticationManager} bean (a
+ * {@code ProviderManager}). A user-defined bean with the same name
+ * will fail with a duplicate-bean error. To plug in custom
+ * authentication providers, define {@code @Bean AuthenticationProvider}
+ * beans (auto-merged - see the next entry) or add their bean names to
+ * {@code grails.plugin.springsecurity.providerNames}.
+ *
{@code @Bean AuthenticationProvider} - user-defined
+ * {@code AuthenticationProvider} beans are auto-merged
+ * into the plugin's {@code authenticationManager}. User providers are
+ * appended so the plugin's primary GORM-backed provider runs first;
+ * providers already in the manager (e.g. those declared via
+ * {@code providerNames}) are not re-added. Disable via
+ * {@code grails.plugin.springsecurity.componentBased.autoMergeAuthenticationProviders: false}.
+ *
{@code @Bean UserDetailsManager} /
+ * {@code InMemoryUserDetailsManager} /
+ * {@code JdbcUserDetailsManager} - for each additional
+ * {@code UserDetailsService} bean, a new
+ * {@code DaoAuthenticationProvider} is created and appended to the
+ * plugin's {@code 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. (We cannot
+ * rewire the existing {@code daoAuthenticationProvider} because Spring
+ * Security 7 made its {@code userDetailsService} a final
+ * constructor-only field.) Disable via
+ * {@code grails.plugin.springsecurity.componentBased.autoChainUserDetailsServices: false}.
+ *
{@code spring.security.user.name} /
+ * {@code spring.security.user.password} /
+ * {@code spring.security.user.roles} - if
+ * {@code spring.security.user.name} is set, an
+ * {@code InMemoryUserDetailsManager} is created from those properties
+ * (mimicking what Spring Boot's
+ * {@code UserDetailsServiceAutoConfiguration} would have done), wrapped
+ * in a {@code DaoAuthenticationProvider} and added to the plugin's
+ * authenticationManager. Disable via
+ * {@code grails.plugin.springsecurity.componentBased.bridgeSpringSecurityUserProperties: false}.
+ *
LDAP factory beans
+ * ({@code EmbeddedLdapServerContextSourceFactoryBean},
+ * {@code LdapBindAuthenticationManagerFactory},
+ * {@code LdapPasswordComparisonAuthenticationManagerFactory})
+ * - the {@code grails-spring-security-ldap} plugin provides equivalent
+ * configuration through {@code grails.plugin.springsecurity.ldap.*}.
+ * User-defined LDAP factory beans coexist but are not wired into the
+ * plugin's authentication providers.
+ *
+ *
+ *
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, set the following property in {@code application.yml}:
+ * 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}:
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.
@@ -59,65 +173,113 @@ import org.springframework.core.env.Environment
* @see AutoConfigurationImportFilter
*/
@CompileStatic
+@Slf4j
class SecurityAutoConfigurationExcluder implements AutoConfigurationImportFilter, EnvironmentAware {
- static final String ENABLED_PROPERTY = 'grails.plugin.springsecurity.excludeSpringSecurityAutoConfiguration'
+ static final String ENABLED_PROPERTY = 'grails.plugin.springsecurity.excludeSpringSecurityAutoConfiguration'
- private boolean enabled = true
+ private boolean enabled = true
- @Override
- void setEnvironment(Environment environment) {
- this.enabled = environment.getProperty(ENABLED_PROPERTY, Boolean, 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 security auto-configuration classes that conflict with the
- * Grails Spring Security plugin. These are excluded unconditionally when the
- * plugin is on the classpath.
- *
- *
- *
{@code SecurityAutoConfiguration} — creates a default {@code SecurityFilterChain}
- * that conflicts with the plugin's {@code FilterChainProxy}
- *
{@code SecurityFilterAutoConfiguration} — registers a
- * {@code DelegatingFilterProxyRegistrationBean} that duplicates the plugin's
- * {@code springSecurityFilterChainRegistrationBean}
- *
{@code UserDetailsServiceAutoConfiguration} — creates an in-memory
- * {@code UserDetailsService} that conflicts with the plugin's
- * {@code GormUserDetailsService}
- *
{@code OAuth2ClientAutoConfiguration} ({@code ...oauth2.client.servlet}) —
- * conflicts when the plugin-oauth2 module manages OAuth2 configuration
- *
{@code OAuth2ClientAutoConfiguration} ({@code ...oauth2.client}) —
- * non-servlet variant of the above; also conflicts with plugin-oauth2
- *
{@code OAuth2ResourceServerAutoConfiguration} — conflicts with the
- * plugin's resource server security setup
- *
{@code ManagementWebSecurityAutoConfiguration} — Actuator security
- * that conflicts when Actuator is on the classpath
- *
- */
- private static final Set EXCLUDED_AUTO_CONFIGURATIONS = [
- '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',
- ].toSet().asImmutable()
+ /**
+ * 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.
+ *
+ *
+ *
{@code SecurityAutoConfiguration} - enables {@code SecurityProperties}
+ * and contributes {@code AuthenticationEventPublisher}/{@code SecurityDataConfiguration}
+ * that conflict with the plugin's wiring
+ *
{@code UserDetailsServiceAutoConfiguration} - creates an in-memory
+ * {@code UserDetailsService} from {@code spring.security.user.*}
+ * properties that conflicts with the plugin's
+ * {@code GormUserDetailsService}
+ *
{@code SecurityFilterAutoConfiguration} - registers a
+ * {@code DelegatingFilterProxyRegistrationBean} that duplicates the
+ * plugin's {@code springSecurityFilterChainRegistrationBean}
+ *
{@code ServletWebSecurityAutoConfiguration} - new in Spring Boot 4;
+ * contributes the servlet {@code SecurityFilterChain} wiring that
+ * conflicts with the plugin's filter chain
+ *
{@code ManagementWebSecurityAutoConfiguration} - Actuator security
+ * that conflicts when Actuator is on the classpath
+ *
{@code OAuth2ClientAutoConfiguration} - registers the
+ * {@code ClientRegistrationRepository} and authorized-client services
+ * that the {@code spring-security-oauth2} plugin owns
+ *
{@code OAuth2ClientWebSecurityAutoConfiguration} - registers the
+ * OAuth2 client servlet {@code SecurityFilterChain} that duplicates
+ * the plugin's OAuth2 client filter chain
+ *
{@code OAuth2ResourceServerAutoConfiguration} (servlet) - configures
+ * a JWT/Opaque-token-based {@code SecurityFilterChain} that conflicts
+ * with the plugin's REST/token authentication wiring
+ *
{@code Saml2RelyingPartyAutoConfiguration} - registers a SAML2
+ * relying-party {@code SecurityFilterChain} that conflicts with the
+ * plugin's SAML wiring
+ *
{@code OAuth2AuthorizationServerAutoConfiguration} (servlet) -
+ * registers a high-precedence authorization-server
+ * {@code SecurityFilterChain} plus a default
+ * {@code anyRequest().authenticated()} chain that would override the
+ * plugin's request-mapping rules
+ *
{@code OAuth2AuthorizationServerJwtAutoConfiguration} (servlet) -
+ * authorization-server JWT companion that pulls in additional
+ * authorization-server beans
+ *
+ */
+ 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[]
- }
+ @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
- }
+ /**
+ * 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/SecurityEventListener.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy
index eb807671c..d55e59fb7 100644
--- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy
@@ -22,7 +22,7 @@ import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.context.ApplicationEvent
import org.springframework.context.ApplicationListener
-import org.springframework.security.access.event.AbstractAuthorizationEvent
+import org.springframework.security.authorization.event.AuthorizationEvent
import org.springframework.security.authentication.event.AbstractAuthenticationEvent
import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent
import org.springframework.security.authentication.event.AuthenticationSuccessEvent
@@ -81,7 +81,7 @@ class SecurityEventListener implements ApplicationListener, Ap
call e, 'onAuthenticationSwitchUserEvent'
}
}
- else if (e instanceof AbstractAuthorizationEvent) {
+ else if (e instanceof AuthorizationEvent) {
call e, 'onAuthorizationEvent'
}
}
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 3232093d7..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
@@ -58,19 +59,15 @@ import grails.plugin.springsecurity.web.filter.GrailsAnonymousAuthenticationFilt
import grails.plugin.springsecurity.web.filter.GrailsRememberMeAuthenticationFilter
import grails.plugin.springsecurity.web.filter.IpAddressFilter
import grails.plugins.Plugin
-import grails.util.Metadata
import groovy.util.logging.Slf4j
import org.grails.web.mime.HttpServletResponseExtension
-import org.springframework.boot.autoconfigure.security.SecurityProperties
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean
import org.springframework.cache.jcache.JCacheCacheManager
-import org.springframework.core.Ordered
import org.springframework.expression.spel.standard.SpelExpressionParser
-import org.springframework.security.access.event.LoggerListener
+import org.springframework.security.authentication.event.LoggerListener
import org.springframework.security.access.expression.DenyAllPermissionEvaluator
import org.springframework.security.access.hierarchicalroles.RoleHierarchyAuthoritiesMapper
-import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl
import org.springframework.security.access.intercept.AfterInvocationProviderManager
import org.springframework.security.access.intercept.NullRunAsManager
import org.springframework.security.access.vote.AuthenticatedVoter
@@ -84,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
@@ -228,12 +226,13 @@ class SpringSecurityCoreGrailsPlugin extends Plugin {
springSecurityBeanFactoryPostProcessor(classFor('springSecurityBeanFactoryPostProcessor', SpringSecurityBeanFactoryPostProcessor))
// configure the filter and optionally the listener
+ int filterOrder = securityFilterOrder()
springSecurityFilterChainRegistrationBean(classFor('springSecurityFilterChainRegistrationBean', FilterRegistrationBean)) {
filter = ref('springSecurityFilterChain')
urlPatterns = ['/*']
dispatcherTypes = EnumSet.of(DispatcherType.ERROR, DispatcherType.REQUEST)
- order = SecurityProperties.DEFAULT_FILTER_ORDER
+ order = filterOrder
}
if (conf.useHttpSessionEventPublisher) {
@@ -381,7 +380,6 @@ class SpringSecurityCoreGrailsPlugin extends Plugin {
forceHttps = conf.auth.forceHttps // false
useForward = conf.auth.useForward // false
portMapper = ref('portMapper')
- portResolver = ref('portResolver')
redirectStrategy = ref('redirectStrategy')
}
@@ -476,8 +474,7 @@ to default to 'Annotation'; setting value to 'Annotation'
preAuthenticationChecks(classFor('preAuthenticationChecks', DefaultPreAuthenticationChecks))
postAuthenticationChecks(classFor('postAuthenticationChecks', DefaultPostAuthenticationChecks))
- daoAuthenticationProvider(classFor('daoAuthenticationProvider', DaoAuthenticationProvider)) {
- userDetailsService = ref('userDetailsService')
+ daoAuthenticationProvider(classFor('daoAuthenticationProvider', DaoAuthenticationProvider), ref('userDetailsService')) {
passwordEncoder = ref('passwordEncoder')
userCache = ref('userCache')
preAuthenticationChecks = ref('preAuthenticationChecks')
@@ -701,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 ?: [])
@@ -774,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)) {
@@ -866,7 +916,7 @@ to default to 'Annotation'; setting value to 'Annotation'
// the hierarchy string is set in doWithApplicationContext to support building
// from the database using GORM if roleHierarchyEntryClassName is set
- roleHierarchy(classFor('roleHierarchy', RoleHierarchyImpl))
+ roleHierarchy(classFor('roleHierarchy', MutableRoleHierarchy))
roleVoter(classFor('roleVoter', RoleHierarchyVoter), ref('roleHierarchy'))
@@ -1040,7 +1090,6 @@ to default to 'Annotation'; setting value to 'Annotation'
requestMatcher(classFor('requestMatcher', AnyRequestMatcher))
requestCache(classFor('requestCache', HttpSessionRequestCache)) {
- portResolver = ref('portResolver')
createSessionAllowed = conf.requestCache.createSession // true
requestMatcher = ref('requestMatcher')
}
@@ -1099,6 +1148,17 @@ to default to 'Annotation'; setting value to 'Annotation'
beanTypeResolver.resolveType beanName, defaultType
}
+ private static int securityFilterOrder() {
+ try {
+ def securityProperties = SpringSecurityCoreGrailsPlugin.classLoader.loadClass(
+ 'org.springframework.boot.autoconfigure.security.SecurityProperties'
+ )
+ return (Integer) securityProperties.getField('DEFAULT_FILTER_ORDER').get(null)
+ }
+ catch (Throwable ignored) {
+ return -100
+ }
+ }
static Map idToPasswordEncoder(ConfigObject conf) {
diff --git a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy
index 1d52118da..3f1718cf1 100644
--- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy
@@ -18,6 +18,8 @@
*/
package grails.plugin.springsecurity.access.vote
+import groovy.util.logging.Slf4j
+
import org.springframework.security.access.AccessDecisionVoter
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.access.ConfigAttribute
@@ -35,6 +37,7 @@ import groovy.transform.CompileStatic
*
* @author Burt Beckwith
*/
+@Slf4j
@CompileStatic
class AuthenticatedVetoableDecisionManager extends AbstractAccessDecisionManager {
@@ -48,7 +51,7 @@ class AuthenticatedVetoableDecisionManager extends AbstractAccessDecisionManager
boolean authenticatedVotersGranted = checkAuthenticatedVoters(authentication, object, configAttributes)
boolean otherVotersGranted = checkOtherVoters(authentication, object, configAttributes)
- logger.trace "decide(): authenticatedVotersGranted=$authenticatedVotersGranted otherVotersGranted=$otherVotersGranted"
+ log.trace "decide(): authenticatedVotersGranted=$authenticatedVotersGranted otherVotersGranted=$otherVotersGranted"
if (!authenticatedVotersGranted && !otherVotersGranted) {
checkAllowIfAllAbstainDecisions()
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:
+ *
+ * @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/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy
index 111724a82..c7ba8e628 100644
--- a/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy
+++ b/plugin-core/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy
@@ -31,11 +31,11 @@ import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.access.AccessDeniedException
-import org.springframework.security.access.ConfigAttribute
import org.springframework.security.access.intercept.AbstractSecurityInterceptor
import org.springframework.security.core.Authentication
import org.springframework.security.web.FilterInvocation
import org.springframework.security.web.access.DefaultWebInvocationPrivilegeEvaluator
+import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource
import grails.util.GrailsUtil
import groovy.transform.CompileStatic
@@ -82,7 +82,8 @@ class GrailsWebInvocationPrivilegeEvaluator extends DefaultWebInvocationPrivileg
log.trace "isAllowed: contextPath '{}' uri '{}' method '{}' Authentication {} FilterInvocation {}",
contextPath, uri, method, authentication, fi
- Collection attrs = interceptor.obtainSecurityMetadataSource().getAttributes(fi)
+ def metadataSource = interceptor.obtainSecurityMetadataSource() as FilterInvocationSecurityMetadataSource
+ def attrs = metadataSource.getAttributes(fi)
if (attrs == null) {
log.trace 'No ConfigAttributes found'
return !interceptor.rejectPublicInvocations
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
index aaec28d4f..3a7298da4 100644
--- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy
+++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy
@@ -27,193 +27,247 @@ import org.springframework.core.env.Environment
/**
* Tests for {@link SecurityAutoConfigurationExcluder}.
*
- * Verifies that Spring Boot security auto-configuration classes that conflict
- * with the Grails Spring Security plugin are filtered out during the
+ * 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.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 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',
- ]
- }
-
- def "match handles mixed array of included and excluded auto-configurations"() {
- given:
- def autoConfigs = [
- 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration',
- 'org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration',
- 'org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration',
- 'org.springframework.boot.autoconfigure.security.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.autoconfigure.security.servlet.SecurityAutoConfiguration',
- ] as String[]
-
- when:
- def results = excluder.match(autoConfigs, null)
-
- then: 'still works correctly'
- !results[0]
- }
-
- def "getExcludedAutoConfigurations returns all 7 known conflicting classes"() {
- when:
- def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations
-
- then:
- excluded.size() == 7
- excluded.contains('org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration')
- excluded.contains('org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration')
- excluded.contains('org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration')
- excluded.contains('org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration')
- excluded.contains('org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration')
- excluded.contains('org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration')
- excluded.contains('org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration')
- }
-
- 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.autoconfigure.security.servlet.SecurityAutoConfiguration',
- 'org.springframework.boot.autoconfigure.security.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 no property set"() {
- given:
- def env = Mock(Environment)
- env.getProperty(SecurityAutoConfigurationExcluder.ENABLED_PROPERTY, Boolean, true) >> true
- excluder.environment = env
-
- and:
- def autoConfigs = [
- 'org.springframework.boot.autoconfigure.security.servlet.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.autoconfigure.security.servlet.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')
- }
- }
+ @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/SecurityEventListenerSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy
index e24bb3f83..1e5c4f328 100644
--- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy
+++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy
@@ -18,7 +18,8 @@
*/
package grails.plugin.springsecurity
-import org.springframework.security.access.event.AbstractAuthorizationEvent
+import org.springframework.security.authorization.AuthorizationDecision
+import org.springframework.security.authorization.event.AuthorizationEvent
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.TestingAuthenticationToken
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent
@@ -77,12 +78,12 @@ class SecurityEventListenerSpec extends AbstractUnitSpec {
called
}
- void 'Test handling AbstractAuthorizationEvent'() {
+ void 'Test handling AuthorizationEvent'() {
when:
boolean called = false
closures.onAuthorizationEvent = { e, appCtx -> called = true }
- listener.onApplicationEvent new AbstractAuthorizationEvent(42) {}
+ listener.onApplicationEvent new AuthorizationEvent({ new TestingAuthenticationToken('', '') }, 42, new AuthorizationDecision(true))
then:
called
diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy
index 6348432d6..401edd314 100644
--- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy
+++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy
@@ -218,7 +218,7 @@ class SpringSecurityUtilsSpec extends AbstractUnitSpec {
void 'isAjax using SavedRequest, false'() {
when:
- def savedRequest = new DefaultSavedRequest(request, new PortResolverImpl())
+ def savedRequest = new DefaultSavedRequest(request)
request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest
then:
@@ -228,7 +228,7 @@ class SpringSecurityUtilsSpec extends AbstractUnitSpec {
void 'isAjax using SavedRequest, true'() {
when:
request.addHeader 'X-Requested-With', 'true'
- def savedRequest = new DefaultSavedRequest(request, new PortResolverImpl())
+ def savedRequest = new DefaultSavedRequest(request)
request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest
then:
@@ -238,7 +238,7 @@ class SpringSecurityUtilsSpec extends AbstractUnitSpec {
void 'isAjax using SavedRequest, XMLHttpRequest'() {
when:
request.addHeader 'X-Requested-With', 'XMLHttpRequest'
- def savedRequest = new DefaultSavedRequest(request, new PortResolverImpl())
+ def savedRequest = new DefaultSavedRequest(request)
request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest
then:
@@ -431,7 +431,7 @@ class SpringSecurityUtilsSpec extends AbstractUnitSpec {
private void initRoleHierarchy(String hierarchyString) {
defineBeans {
- roleHierarchy(RoleHierarchyImpl) {
+ roleHierarchy(MutableRoleHierarchy) {
hierarchy = hierarchyString
}
}
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-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy
new file mode 100644
index 000000000..b21adf64a
--- /dev/null
+++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy
@@ -0,0 +1,71 @@
+/*
+ * 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.web.access.intercept
+
+import grails.plugin.springsecurity.AbstractUnitSpec
+import org.springframework.security.access.AccessDecisionManager
+import org.springframework.security.access.SecurityConfig
+import org.springframework.security.web.FilterInvocation
+import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource
+import org.springframework.security.web.access.intercept.FilterSecurityInterceptor
+
+import jakarta.servlet.FilterChain
+
+class FilterSecurityInterceptorSpec extends AbstractUnitSpec {
+
+ void 'doFilter delegates to the chain for public invocations when allowed'() {
+ given:
+ def interceptor = new FilterSecurityInterceptor(
+ rejectPublicInvocations: false,
+ securityMetadataSource: Stub(FilterInvocationSecurityMetadataSource) {
+ getAttributes(_ as FilterInvocation) >> null
+ }
+ )
+ FilterChain chain = Mock()
+
+ when:
+ interceptor.doFilter(request, response, chain)
+
+ then:
+ 1 * chain.doFilter(request, response)
+ }
+
+ void 'doFilter consults the access decision manager before continuing the chain'() {
+ given:
+ def attributes = [new SecurityConfig('ROLE_USER')]
+ def interceptor = new FilterSecurityInterceptor(
+ rejectPublicInvocations: false,
+ securityMetadataSource: Stub(FilterInvocationSecurityMetadataSource) {
+ getAttributes(_ as FilterInvocation) >> attributes
+ }
+ )
+ interceptor.accessDecisionManager = Mock(AccessDecisionManager) {
+ 1 * decide(null, _ as FilterInvocation, attributes)
+ }
+ FilterChain chain = Mock()
+
+ when:
+ interceptor.doFilter(request, response, chain)
+
+ then:
+ 1 * chain.doFilter(request, response)
+ }
+}
+
+
diff --git a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy
index f55c13b1c..db8294e9b 100644
--- a/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy
+++ b/plugin-core/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy
@@ -22,6 +22,7 @@ import grails.plugin.springsecurity.AbstractUnitSpec
import grails.plugin.springsecurity.ReflectionUtils
import grails.plugin.springsecurity.SpringSecurityUtils
import grails.plugin.springsecurity.web.SecurityRequestHolder
+import org.springframework.security.web.RedirectStrategy
/**
* Unit tests for WithAjaxAuthenticationProcessingFilterEntryPoint.
@@ -60,6 +61,23 @@ class AjaxAwareAuthenticationEntryPointSpec extends AbstractUnitSpec {
ajaxLoginFormUrl == response.forwardedUrl
}
+ void 'commence() redirects without legacy portResolver wiring'() {
+ given:
+ entryPoint.useForward = false
+ String redirectedUrl
+ entryPoint.redirectStrategy = Stub(RedirectStrategy) {
+ sendRedirect(_, _, _) >> { requestArg, responseArg, url ->
+ redirectedUrl = url as String
+ }
+ }
+
+ when:
+ entryPoint.commence request, response, null
+
+ then:
+ loginFormUrl == redirectedUrl
+ }
+
void 'setAjaxLoginFormUrl'() {
when:
entryPoint.ajaxLoginFormUrl = 'foo'
diff --git a/plugin-core/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy
new file mode 100644
index 000000000..67892e0c2
--- /dev/null
+++ b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy
@@ -0,0 +1,147 @@
+/*
+ * 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 org.springframework.security.access.intercept.aopalliance
+
+import grails.plugin.springsecurity.SecurityTestUtils
+import org.aopalliance.aop.Advice
+import org.aopalliance.intercept.MethodInvocation
+import org.springframework.security.access.AccessDecisionManager
+import org.springframework.security.access.SecurityConfig
+import org.springframework.security.access.intercept.AfterInvocationManager
+import org.springframework.security.access.intercept.RunAsManagerImpl
+import org.springframework.security.access.method.MethodSecurityMetadataSource
+import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.context.SecurityContextHolder
+import spock.lang.Specification
+
+class MethodSecurityInterceptorSpec extends Specification {
+
+ void cleanup() {
+ SecurityTestUtils.logout()
+ }
+
+ void 'invoke acts as advice and applies access and after-invocation processing'() {
+ given:
+ Authentication authentication = SecurityTestUtils.authenticate(['ROLE_USER'])
+ def method = String.getMethod('trim')
+ def invocation = Stub(MethodInvocation) {
+ getMethod() >> method
+ getThis() >> ' ok '
+ proceed() >> 'ok'
+ }
+ def attributes = [new SecurityConfig('ROLE_USER')]
+ def interceptor = new MethodSecurityInterceptor(
+ securityMetadataSource: Stub(MethodSecurityMetadataSource) {
+ getAttributes(method, String) >> attributes
+ },
+ accessDecisionManager: Mock(AccessDecisionManager) {
+ 1 * decide(authentication, invocation, attributes)
+ },
+ afterInvocationManager: Mock(AfterInvocationManager) {
+ 1 * decide(authentication, invocation, attributes, 'ok') >> 'filtered'
+ }
+ )
+
+ expect:
+ interceptor instanceof Advice
+ interceptor.invoke(invocation) == 'filtered'
+ }
+
+ void 'invoke proceeds directly when there are no security attributes'() {
+ given:
+ def method = String.getMethod('trim')
+ def invocation = Stub(MethodInvocation) {
+ getMethod() >> method
+ getThis() >> ' ok '
+ proceed() >> 'ok'
+ }
+ def interceptor = new MethodSecurityInterceptor(
+ securityMetadataSource: Stub(MethodSecurityMetadataSource) {
+ getAttributes(method, String) >> null
+ }
+ )
+
+ expect:
+ interceptor.invoke(invocation) == 'ok'
+ }
+
+ void 'invoke fails with credentials not found when secured invocation has no authentication'() {
+ given:
+ def method = String.getMethod('trim')
+ def invocation = Stub(MethodInvocation) {
+ getMethod() >> method
+ getThis() >> ' ok '
+ }
+ def interceptor = new MethodSecurityInterceptor(
+ securityMetadataSource: Stub(MethodSecurityMetadataSource) {
+ getAttributes(method, String) >> [new SecurityConfig('ROLE_USER')]
+ }
+ )
+
+ when:
+ interceptor.invoke(invocation)
+
+ then:
+ thrown AuthenticationCredentialsNotFoundException
+ }
+
+ void 'invoke applies run-as authentication during the secured invocation and restores the original authentication afterwards'() {
+ given:
+ Authentication authentication = SecurityTestUtils.authenticate(['ROLE_ADMIN'])
+ def method = String.getMethod('trim')
+ def attributes = [new SecurityConfig('ROLE_ADMIN'), new SecurityConfig('RUN_AS_SUPERUSER')]
+ def interceptor = new MethodSecurityInterceptor(
+ securityMetadataSource: Stub(MethodSecurityMetadataSource) {
+ getAttributes(method, String) >> attributes
+ },
+ accessDecisionManager: Mock(AccessDecisionManager) {
+ 1 * decide(authentication, _ as MethodInvocation, attributes)
+ },
+ runAsManager: new RunAsManagerImpl(),
+ afterInvocationManager: Mock(AfterInvocationManager) {
+ 1 * decide(_ as Authentication, _ as MethodInvocation, attributes, 'ok') >> { Authentication activeAuthentication, MethodInvocation ignored, Collection ignoredAttributes, Object returnedObject ->
+ assert activeAuthentication.authorities*.authority.contains('ROLE_RUN_AS_SUPERUSER')
+ return returnedObject
+ }
+ }
+ )
+ def invocation = Stub(MethodInvocation) {
+ getMethod() >> method
+ getThis() >> ' ok '
+ proceed() >> {
+ assert SecurityContextHolder.context.authentication.authorities*.authority.contains('ROLE_RUN_AS_SUPERUSER')
+ 'ok'
+ }
+ }
+
+ when:
+ def result = interceptor.invoke(invocation)
+
+ then:
+ result == 'ok'
+ SecurityContextHolder.context.authentication.is(authentication)
+ !SecurityContextHolder.context.authentication.authorities*.authority.contains('ROLE_RUN_AS_SUPERUSER')
+ }
+}
+
+
+
+
+
diff --git a/plugin-core/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy
new file mode 100644
index 000000000..5b96cf886
--- /dev/null
+++ b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy
@@ -0,0 +1,60 @@
+/*
+ * 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 org.springframework.security.access.method
+
+import org.springframework.security.access.annotation.Secured
+import org.springframework.security.access.annotation.SecuredAnnotationSecurityMetadataSource
+import spock.lang.Specification
+
+class AbstractFallbackMethodSecurityMetadataSourceSpec extends Specification {
+
+ private final SecuredAnnotationSecurityMetadataSource metadataSource = new SecuredAnnotationSecurityMetadataSource()
+
+ void 'getAttributes prefers method metadata from the concrete target class over class metadata'() {
+ given:
+ def method = SecuredService.getMethod('userAnnotated')
+
+ expect:
+ metadataSource.getAttributes(method, SecuredServiceImpl)*.attribute == ['ROLE_USER']
+ }
+
+ void 'getAttributes falls back to class metadata when the concrete target method has no annotation'() {
+ given:
+ def method = SecuredService.getMethod('notAnnotated')
+
+ expect:
+ metadataSource.getAttributes(method, SecuredServiceImpl)*.attribute == ['ROLE_ADMIN']
+ }
+
+ private static interface SecuredService {
+ void notAnnotated()
+ void userAnnotated()
+ }
+
+ @Secured('ROLE_ADMIN')
+ private static class SecuredServiceImpl implements SecuredService {
+ @Override
+ void notAnnotated() {}
+
+ @Override
+ @Secured('ROLE_USER')
+ void userAnnotated() {}
+ }
+}
+
diff --git a/plugin-core/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy
new file mode 100644
index 000000000..954d129d0
--- /dev/null
+++ b/plugin-core/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy
@@ -0,0 +1,155 @@
+/*
+ * 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 org.springframework.security.access.prepost
+
+import grails.plugin.springsecurity.SecurityTestUtils
+import org.aopalliance.intercept.MethodInvocation
+import org.springframework.security.access.PermissionEvaluator
+import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler
+import org.springframework.security.access.expression.method.ExpressionBasedAnnotationAttributeFactory
+import org.springframework.security.access.expression.method.ExpressionBasedPostInvocationAdvice
+import org.springframework.security.access.expression.method.ExpressionBasedPreInvocationAdvice
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.parameters.P
+import spock.lang.Specification
+
+class MethodSecurityExpressionCompatibilitySpec extends Specification {
+
+ void cleanup() {
+ SecurityTestUtils.logout()
+ }
+
+ void 'metadata source extracts pre and post annotations from secured methods'() {
+ given:
+ def metadataSource = new PrePostAnnotationSecurityMetadataSource(
+ new ExpressionBasedAnnotationAttributeFactory(Stub(DefaultMethodSecurityExpressionHandler))
+ )
+ def method = SecuredService.getMethod('getReports')
+
+ when:
+ def attribute = metadataSource.getAttributes(method, SecuredService).first() as ExpressionBasedAnnotationConfigAttribute
+
+ then:
+ attribute.preAuthorizeExpression == 'hasRole("ROLE_USER")'
+ attribute.postFilterExpression == 'hasPermission(filterObject, read)'
+ }
+
+ void 'pre-invocation voter denies and grants based on the evaluated pre-authorize expression'() {
+ given:
+ PermissionEvaluator permissionEvaluator = Mock()
+ Authentication authentication = SecurityTestUtils.authenticate(['ROLE_USER'])
+ def advice = new ExpressionBasedPreInvocationAdvice(
+ expressionHandler: Stub(DefaultMethodSecurityExpressionHandler) {
+ getPermissionEvaluator() >> permissionEvaluator
+ }
+ )
+ def voter = new PreInvocationAuthorizationAdviceVoter(advice)
+ def reflectedMethod = SecuredService.getMethod('getReport', Long)
+ MethodInvocation invocation = Stub(MethodInvocation)
+ invocation.getMethod() >> reflectedMethod
+ invocation.getArguments() >> ([42L] as Object[])
+ def attribute = new ExpressionBasedAnnotationConfigAttribute(
+ 'hasPermission(#id, "com.testacl.Report", read)', null, null, null)
+
+ when:
+ def denied = voter.vote(authentication, invocation, [attribute])
+
+ then:
+ denied == PreInvocationAuthorizationAdviceVoter.ACCESS_DENIED
+ 1 * permissionEvaluator.hasPermission(authentication, 42L, 'com.testacl.Report', _) >> false
+
+ when:
+ def granted = voter.vote(authentication, invocation, [attribute])
+
+ then:
+ granted == PreInvocationAuthorizationAdviceVoter.ACCESS_GRANTED
+ 1 * permissionEvaluator.hasPermission(authentication, 42L, 'com.testacl.Report', _) >> true
+ }
+
+ void 'pre-invocation voter exposes the create ACL permission alias'() {
+ given:
+ PermissionEvaluator permissionEvaluator = Mock()
+ Authentication authentication = SecurityTestUtils.authenticate(['ROLE_USER'])
+ def advice = new ExpressionBasedPreInvocationAdvice(
+ expressionHandler: Stub(DefaultMethodSecurityExpressionHandler) {
+ getPermissionEvaluator() >> permissionEvaluator
+ }
+ )
+ def voter = new PreInvocationAuthorizationAdviceVoter(advice)
+ def reflectedMethod = SecuredService.getMethod('createReport', Long)
+ MethodInvocation invocation = Stub(MethodInvocation)
+ invocation.getMethod() >> reflectedMethod
+ invocation.getArguments() >> ([42L] as Object[])
+ def attribute = new ExpressionBasedAnnotationConfigAttribute(
+ 'hasPermission(#id, "com.testacl.Report", create)', null, null, null)
+
+ when:
+ def granted = voter.vote(authentication, invocation, [attribute])
+
+ then:
+ granted == PreInvocationAuthorizationAdviceVoter.ACCESS_GRANTED
+ 1 * permissionEvaluator.hasPermission(authentication, 42L, 'com.testacl.Report', 4) >> true
+ }
+
+ void 'post-invocation advice filters returned collections using post-filter expressions'() {
+ given:
+ PermissionEvaluator permissionEvaluator = Mock()
+ Authentication authentication = SecurityTestUtils.authenticate(['ROLE_USER'])
+ def advice = new ExpressionBasedPostInvocationAdvice(Stub(DefaultMethodSecurityExpressionHandler) {
+ getPermissionEvaluator() >> permissionEvaluator
+ })
+ def reflectedMethod = SecuredService.getMethod('getReports')
+ MethodInvocation invocation = Stub(MethodInvocation)
+ invocation.getMethod() >> reflectedMethod
+ invocation.getArguments() >> ([] as Object[])
+ def attribute = new ExpressionBasedAnnotationConfigAttribute(
+ null, null, null, 'hasPermission(filterObject, read)')
+ def report1 = new Object()
+ def report2 = new Object()
+
+ when:
+ def filtered = advice.after(authentication, invocation, attribute, [report1, report2])
+
+ then:
+ filtered == [report1]
+ 1 * permissionEvaluator.hasPermission(authentication, report1, _) >> true
+ 1 * permissionEvaluator.hasPermission(authentication, report2, _) >> false
+ }
+
+ private static class SecuredService {
+ @PreAuthorize('hasPermission(#id, "com.testacl.Report", read)')
+ Object getReport(@P('id') Long id) {
+ null
+ }
+
+ @PreAuthorize('hasPermission(#id, "com.testacl.Report", create)')
+ Object createReport(@P('id') Long id) {
+ null
+ }
+
+ @PreAuthorize('hasRole("ROLE_USER")')
+ @PostFilter('hasPermission(filterObject, read)')
+ List