diff --git a/.agents/skills/mono-repo-integration/SKILL.md b/.agents/skills/mono-repo-integration/SKILL.md new file mode 100644 index 00000000000..5e58b769236 --- /dev/null +++ b/.agents/skills/mono-repo-integration/SKILL.md @@ -0,0 +1,167 @@ + +--- +name: mono-repo-integration +description: Step-by-step process for merging a previously-standalone Grails plugin repository (e.g. grails-spring-security, grails-redis) into the grails-core monorepo as one or more Gradle subprojects, wiring it into the shared build, publishing, docs, and CI the same way the existing modules are. +license: Apache-2.0 +compatibility: opencode, claude, grok, gemini, copilot, cursor, windsurf +metadata: + audience: maintainers + frameworks: grails + versions: 7 +--- + +## What I Do + +- Merge a standalone Grails plugin repo (its source was copied into a top-level folder such as `grails-/`) into the grails-core monorepo build. +- Strip the imported repo's standalone build/release/CI infrastructure and rewire its modules onto the monorepo's **shared** Gradle config, publishing, docs guide, and CI. + +This skill is the generalization of the **Spring Security merge** (git commits prefixed `Spring Security Merge - ...`, starting at `6d06f6c84f` / `fd1939a7e2`). When in doubt, read those commits — they are the canonical worked example: + +```bash +git log --oneline --grep "Spring Security Merge" +git show # inspect any individual step +``` + +## Guiding Principles (NON-NEGOTIABLE) + +1. **No custom/duplicated gradle files.** The imported repo ships its own `gradle/*.gradle` (test-config, publish-config, java-config, docs-config, reproducible-config, rat-root-config, examples-config, etc.). **Delete them all.** Every module must `apply from:` the monorepo's existing root `gradle/*.gradle` files instead. +2. **No hard-coded dependency versions.** The monorepo uses the Grails BOM (`grails-bom`). Drop version numbers from the imported `build.gradle` files and from `gradle.properties`; rely on `platform("org.apache.grails:grails-bom:$grailsVersion")`. Only add a version to `dependencies.gradle` (and reference it) if the dependency is genuinely not already managed by a BOM. Check first: `grep -i '' dependencies.gradle grails-bom/*/build/*-constraints.adoc`. +3. **Publish the same way.** Add each published module to `publishedProjects` in `gradle/publish-root-config.gradle`, and have each module `apply from: '/gradle/publish-config.gradle'` (the monorepo's, not the imported one). Do not keep a per-repo publish-config. +4. **Integrate authors into the publish plugin.** Merge the imported repo's developer list into `build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/PublishPlugin.groovy`, keeping each list alphabetized by handle. Two rules: + - **Dedupe against ALL author lists** in `PublishPlugin.groovy` — `founder(...)`, `developer(...)`, `contributor(...)`, and `emeritus(...)` — and match on the person, not just the handle (e.g. `christianoestreich` may already be present as `ctoestreich`; `ldaley` as `alkemist`; `graemerocher` is a `founder`; `sbglasius`/`matrei` are active `developer`s; `burtbeckwith`/`puneetbehl`/`pledbrook`/`marcpalmer`/`jeffscottbrown` are `emeritus`). Never add someone already in any list under any handle. + - **Classify by recency, defaulting to `emeritus`.** Check each author's most recent commit (`git log --all --author="" --format=%ad --date=short -1`). If they have not contributed recently, add them as `emeritus(...)`, not `contributor(...)`. Most authors from a long-dormant imported plugin will be emeritus. Only use `contributor(...)` for genuinely active contributors. +5. **Functional/example apps live under `grails-test-examples/`.** Move the imported repo's `examples/*` and `*-test-app` projects out of the plugin folder into `grails-test-examples//...` and wire them through `gradle/functional-test-config.gradle`. +6. **Docs go into the guide.** Migrate documentation into `grails-doc/src/en/guide/...` as AsciiDoc. There is no standalone docs subproject. Markdown READMEs must be converted to `.adoc`. +7. **Drop historical release history and author/changelog sections from docs.** The monorepo guide does not store per-plugin release history, "previous work", or author lists. Remove `history.adoc`, `authors.adoc`, `previouswork.adoc`, changelog tables, and README "release history" sections. +8. **Remove hard-coded URLs to the old project docs.** Replace absolute links to the legacy standalone documentation site with guide-relative cross-references or BOM/attribute-driven links (see `grails-doc/build.gradle` attribute map). +9. Follow `CLAUDE.md` rules throughout: `jakarta.*` not `javax.*`, Apache license header on every new file, 4-space indent, no wildcard imports, tests via public APIs. +10. **Inter-module dependency syntax differs by project kind.** The plugin/library modules themselves depend on sibling monorepo modules via **`project(':grails-...')`**. The `grails-test-examples/` apps depend on those same modules via **Maven coordinates** (`'org.apache.grails:grails-...'`), consuming them as published artifacts. Do not mix these up — convert the imported `project(...)` references in example/test apps to coordinates during Phase 4. + +## Phased Process + +Mirror the Spring Security commit sequence. Make one focused commit per phase, messaged ` Merge - `. + +### Phase 0 — Import the repository (preserving history) +Bring the standalone repo in under a top-level `grails-/` prefix using a subtree-style merge, so the imported commit history is preserved (joined via `-s ours`) while the working tree is populated from `read-tree`. Add the source repo as a remote first (`git remote add grails- && git fetch grails-`), then: + +```bash +# is the imported repo's release branch, e.g. grails-redis/5.0.x +git merge -s ours --no-commit --allow-unrelated-histories grails-/ +git read-tree --prefix=grails-/ -u grails-/ +git commit -m "Initial import of Grails Repository" +``` + +This produces the single `Initial import of Grails Repository` commit (the starting point the rest of this skill restructures). Example actually used for redis: + +```bash +git merge -s ours --no-commit --allow-unrelated-histories grails-redis/5.0.x +git read-tree --prefix=grails-redis/ -u grails-redis/5.0.x +git commit -m "Initial import of Grails Redis Repository" +``` + +### Phase 0.5 — Survey +- `git log --oneline | grep -i "Initial import"` to find the import commit. +- Map the imported tree: `find grails- -type d`. Identify: plugin module(s), example/test apps, docs (adoc or README), the developer list (`gradle/publish-config.gradle` → `it.developers`), and all standalone infra. +- List what the monorepo already provides so you reuse it: `ls gradle/`, `publishedProjects` in `gradle/publish-root-config.gradle`, the `contributor(...)` block in `PublishPlugin.groovy`, the guide layout under `grails-doc/src/en/guide/`, and the CI test-filter flags in `DEVELOPMENT.md`. + +### Phase 1 — Initial Moves (examine infra, then port-or-delete + restructure) +**Do not blindly delete.** Most of the imported repo's standalone infrastructure is removed because the monorepo already provides it — but several files carry repo-specific customizations that must be *carried over* into the monorepo's equivalents first. Examine each, decide port-or-delete, then delete the standalone copy. `git diff` the imported file against the monorepo's equivalent to see exactly what is custom. + +**Examine and port (customizations must survive):** +- **`NOTICE` / `LICENSE`** — first determine whether they are *standard* (boilerplate Apache header/notice) or *customized*. If standard, just delete them: the monorepo's shared gradle plugins (applied to every module) generate/import the generic `LICENSE`/`NOTICE` automatically, so no carry-over is needed. Only when they are customized (bundled third-party components, extra attribution clauses) do you diff against the monorepo's top-level `NOTICE`/`LICENSE` and `licenses/` and merge the repo-specific additions in before deleting the imported copies. +- **`.gitignore`** — may contain custom excludes (generated dirs, plugin-specific artifacts). Fold any non-duplicate entries into the monorepo's root `.gitignore` before deleting. +- **RAT config** (the repo's `gradle/rat-*.gradle`) — almost always lists files that must be excluded for `rat` license validation to pass (templates, generated files, third-party-licensed assets shipped with the plugin). **Port every still-relevant exclude** into the root `gradle/rat-root-config.gradle` (paths rewritten to the new `grails-/...` location). Missing these causes `./gradlew rat` to fail later. (Cross-reference Phase 2.) +- **`gradle.properties`** — versions should generally match the monorepo, but watch for third-party libraries pinned here that *should* be BOM-managed: those must be imported into the BOM (`dependencies.gradle` / `grails-bom`) rather than carried as loose properties. Carry over only genuinely non-BOM-managed props (Phase 2). +- **`.github/workflows/`** — **never blindly delete these.** The monorepo has its own CI, but the imported workflows almost always encode test coverage that must be reproduced exactly. Before deleting, enumerate everything each job does and confirm the monorepo job you add in Phase 2 covers the **same level of testing** — not a single reduced run. In particular capture: (a) **test matrices / config variants** — e.g. grails-spring-security ran its functional tests across a 9-value `-DTESTCONFIG=` matrix (`static`, `annotation`, `requestmap`, `basic`, `basicCacheUsers`, `misc`, `putWithParams`, `bcrypt`, `issue503`); the specs are gated with `@IgnoreIf({ System.getProperty('TESTCONFIG') != '' })`, so a single run silently skips 8/9 configs and looks green while covering almost nothing. Every matrix axis (config, JVM version, container version, DB flavor) must be reproduced. (b) required **service containers** (a `redis`/`postgres` the functional tests need — prefer Testcontainers per Phase 2). (c) extra validations / dependency-setup steps. Write down each axis and value here, then reproduce them in the Phase 2 job. When in doubt, diff the imported job step-by-step against the monorepo job and account for every flag. +- **`buildSrc/`** — usually removable, but inspect for custom tasks/plugins/conventions the build actually depends on; integrate any such behavior into `build-logic/` or the root gradle config before deleting. + +**Examine briefly, then typically delete:** +- **`etc/`** — typically build-verification/release scripts (reproducible-build checks, artifact verification). Usually safe to drop, but do a short scan to confirm nothing the monorepo lacks is referenced by the build. +- **`.asf.yaml`, `.sdkmanrc`, `CODE_OF_CONDUCT.md`, `HEADER`, `ISSUE_TEMPLATE.md`** — standalone-repo metadata superseded by the monorepo's; delete. + +**Delete outright (always superseded by the monorepo):** +- `gradlew`, `gradlew.bat`, `gradle/wrapper/`, `gradle-bootstrap/` +- repo-root `settings.gradle`, root `build.gradle` +- the repo's own `gradle/*.gradle` convention files (test-config, publish-config, java-config, docs-config, reproducible-config, examples-config, and the now-ported rat config) +- `README.md` — its content is migrated to the guide in Phase 3, then deleted. + +**Test-skip property note:** the monorepo's `grails-core` CI workflows pass a skip flag (e.g. `-Pskip`/`-PskipTests`) to exclude this plugin's functional tests from the default runs, and a **separate dedicated workflow** runs them (with any required service containers). Note here what the imported CI needed; wire the flag + dedicated job in Phase 2. + +**Restructure** directories to the monorepo convention. **Initial Moves is pure deletion + relocation — do NOT rewrite file contents here.** Keeping moves and content edits in separate commits means git records relocations as renames, so every later phase's content change diffs cleanly against the moved file instead of appearing as a delete+add. Concretely, the Initial Moves commit: +- **Collapses a single-plugin repo's source up to the repo root** — move `grails-/plugin/{grails-app,src,build.gradle}` to `grails-/` so the plugin project's dir is simply `grails-/` (no `projectDir` mapping needed, since the dir name matches the project name). Multi-module repos (like spring-security) keep nested `plugin`/`docs` folders. +- **Relocates example/functional apps to `grails-test-examples//...`** (e.g. `grails-/examples/` → `grails-test-examples//`), moved verbatim. If the repo has only a **single** functional app, flatten it directly into `grails-test-examples//` (drop the redundant per-app subfolder) and name the project `grails-test-examples-`. +Each deployable module gets a clean folder; nested project dirs are mapped explicitly via `projectDir` in `settings.gradle`, so directory names can differ from project names. The content edits to these moved files (build-script rewrites, dependency-by-coordinate conversion, applying root gradle config) happen in the *later* phases and will show as clean diffs. + +### Phase 2 — Integrate the build +Edit, in this order: +- **`settings.gradle`** (root): add each module to the `include(...)` list with a `grails--...` project name, then set `project(':grails--...').projectDir = new File(settingsDir, 'grails-/')`. Add functional/example apps as `grails-test-examples--...` mapped into `grails-test-examples//...`. +- **Each module `build.gradle`**: keep the `plugins { ... }` block and dependencies, but (a) strip versions in favor of the BOM, (b) replace the `apply { from ... }` block to point at the **root** `gradle/*.gradle` files. **Declare all Gradle plugins in the `plugins { }` block** (the composite build resolves the project's `org.apache.grails.*` convention plugins there) rather than the legacy `apply plugin: '...'` form — match the modern test projects (e.g. `grails-test-examples/scaffolding`). Note: `apply from: ' + + \ No newline at end of file diff --git a/grails-spring-security/plugin/grails-app/views/login/denied.gsp b/grails-spring-security/plugin/grails-app/views/login/denied.gsp new file mode 100644 index 00000000000..e77f7f4aeef --- /dev/null +++ b/grails-spring-security/plugin/grails-app/views/login/denied.gsp @@ -0,0 +1,32 @@ +<%-- + ~ 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. + --%> + + + + +<g:message code='springSecurity.denied.title' /> + + + +
+
+
+ + + diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/Application.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/Application.groovy new file mode 100644 index 00000000000..c0da862e022 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/Application.groovy @@ -0,0 +1,35 @@ +/* + * 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 grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration + +/** + * @author Burt Beckwith + */ +@CompileStatic +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + GrailsApp.run Application + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/BeanTypeResolver.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/BeanTypeResolver.groovy new file mode 100644 index 00000000000..689b16c8478 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/BeanTypeResolver.groovy @@ -0,0 +1,55 @@ +/* + * 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 grails.core.GrailsApplication + +/** + * Used in doWithSpring to allow overriding of the class of individual Spring beans by setting a property in the config. + * The property name syntax is beanName + 'BeanClass', so for example to override the type of the 'authoritiesMapper' + * bean, add a property authoritiesMapperBeanClass = 'com.foo.Bar' or + * authoritiesMapperBeanClass = com.foo.Bar. + * + * This is useful when a bean override retains all of the configuration options of the original and only the class is + * different. Just overriding the class (ordinarily done with a bean post-processor) allows redefined beans to use new + * or changed properties in future versions of the plugin. + * + * @author Burt Beckwith + */ +@CompileStatic +class BeanTypeResolver { + + protected ConfigObject conf + protected GrailsApplication grailsApplication + + BeanTypeResolver(ConfigObject securityConfig, GrailsApplication application) { + conf = securityConfig + grailsApplication = application + } + + Class resolveType(String beanName, Class defaultType) { + def override = conf[beanName + 'BeanClass'] + if (override instanceof CharSequence) { + override = Class.forName(override.toString(), false, Thread.currentThread().contextClassLoader) + } + override ?: defaultType + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/ControllerMixin.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/ControllerMixin.groovy new file mode 100644 index 00000000000..7140c5bc61a --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/ControllerMixin.groovy @@ -0,0 +1,47 @@ +/* + * 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 org.springframework.security.core.context.SecurityContextHolder + +import grails.artefact.Enhances +import org.grails.core.artefact.ControllerArtefactHandler + +/** + * @author Burt Beckwith + */ +@Enhances(ControllerArtefactHandler.TYPE) +trait ControllerMixin { + + def getPrincipal() { + SecurityContextHolder.context?.authentication?.principal + } + + boolean isLoggedIn() { + springSecurityService().isLoggedIn() + } + + def getAuthenticatedUser() { + springSecurityService().currentUser + } + + private springSecurityService() { + grailsApplication.mainContext.springSecurityService + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/InterceptedUrl.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/InterceptedUrl.groovy new file mode 100644 index 00000000000..a853955b7e3 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/InterceptedUrl.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 grails.plugin.springsecurity + +import groovy.transform.CompileStatic +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString + +import org.springframework.http.HttpMethod +import org.springframework.security.access.ConfigAttribute + +/** + * @author Burt Beckwith + */ +@EqualsAndHashCode(includes = 'pattern,httpMethod') +@ToString(includeNames = true) +@CompileStatic +class InterceptedUrl { + + String pattern + Collection configAttributes = Collections.emptyList() + HttpMethod httpMethod + boolean filters = true + Boolean https // true->https, false->http, null->any + Class closureClass + + InterceptedUrl(String pattern, Collection tokens, HttpMethod httpMethod) { + this.pattern = pattern + this.configAttributes = ReflectionUtils.buildConfigAttributes(tokens) + this.httpMethod = httpMethod + } + + InterceptedUrl(String pattern, HttpMethod httpMethod, Collection configAttributes) { + this.pattern = pattern + this.httpMethod = httpMethod + this.configAttributes = configAttributes + } + + InterceptedUrl(String pattern, Class closureClass, HttpMethod httpMethod) { + this.pattern = pattern + this.closureClass = closureClass + this.httpMethod = httpMethod + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/MutableRoleHierarchy.groovy new file mode 100644 index 00000000000..7a967b901a5 --- /dev/null +++ b/grails-spring-security/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.:

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

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

+ */ +@CompileStatic +class MutableRoleHierarchy implements RoleHierarchy { + + String hierarchy = '' + + private RoleHierarchy delegate = RoleHierarchyImpl.fromHierarchy('') + + void setHierarchy(String hierarchy) { + this.hierarchy = hierarchy ?: '' + delegate = RoleHierarchyImpl.fromHierarchy(this.hierarchy) + } + + @Override + Collection getReachableGrantedAuthorities(Collection authorities) { + delegate.getReachableGrantedAuthorities(authorities) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/ReflectionUtils.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/ReflectionUtils.groovy new file mode 100644 index 00000000000..ad3838f0278 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/ReflectionUtils.groovy @@ -0,0 +1,219 @@ +/* + * 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.util.logging.Slf4j + +import org.springframework.core.env.MapPropertySource +import org.springframework.core.env.PropertySource +import org.springframework.expression.Expression +import org.springframework.expression.ParseException +import org.springframework.http.HttpMethod +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.SecurityConfig +import org.springframework.security.access.expression.SecurityExpressionHandler + +import grails.core.GrailsApplication +import grails.plugin.springsecurity.web.access.expression.WebExpressionConfigAttribute +import grails.util.Holders +import grails.web.mapping.UrlMapping +import grails.web.mapping.UrlMappingInfo +import grails.web.mapping.UrlMappingsHolder +import org.grails.config.PropertySourcesConfig +import org.grails.web.mime.HttpServletResponseExtension +import org.grails.web.servlet.mvc.GrailsWebRequest + +import static grails.web.http.HttpHeaders.ACCEPT_VERSION + +/** + * Helper methods that use dynamic Groovy. + * + * @author Burt Beckwith + */ +@Slf4j +class ReflectionUtils { + + // set at startup + static GrailsApplication application + + private ReflectionUtils() { + // static only + } + + static Object getConfigProperty(String name, config = SpringSecurityUtils.securityConfig) { + def value = config + name.split('\\.').each { String part -> value = value."$part" } + value + } + + static void setConfigProperty(String name, value) { + def config = SpringSecurityUtils.securityConfig + + List parts = name.split('\\.') + name = parts.remove(parts.size() - 1) + + parts.each { String part -> config = config."$part" } + + config."$name" = value + } + + static String getRoleAuthority(role) { + lookupPropertyValue role, 'authority.nameField' + } + + static String getRequestmapUrl(requestmap) { + lookupPropertyValue requestmap, 'requestMap.urlField' + } + + static String getRequestmapConfigAttribute(requestmap) { + lookupPropertyValue requestmap, 'requestMap.configAttributeField' + } + + static HttpMethod getRequestmapHttpMethod(requestmap) { + lookupPropertyValue requestmap, 'requestMap.httpMethodField' + } + + static List loadAllRequestmaps() { + Class Requestmap = requestMapClass + Requestmap.withTransaction { + Requestmap.list() + } + } + + static boolean requestmapClassSupportsHttpMethod() { + String httpMethodField = SpringSecurityUtils.securityConfig.requestMap.httpMethodField + if (!httpMethodField) return false + + requestMapClass.metaClass.properties.find { MetaProperty p -> p.name == httpMethodField } + } + + static Class getRequestMapClass() { + String className = SpringSecurityUtils.securityConfig.requestMap.className ?: '' + assert className, "Cannot load Requestmaps; 'requestMap.className' property is not specified" + + Class Requestmap = getApplication().getClassForName(className) + assert Requestmap, "Cannot load Requestmaps; 'requestMap.className' property '$className' is invalid" + + Requestmap + } + + static List asList(o) { o ? o as List : [] } + + static ConfigObject getSecurityConfig() { + def grailsConfig = getApplication().config + if (grailsConfig.containsProperty('grails.plugins.springsecurity')) { + log.error "Your security configuration settings use the old prefix 'grails.plugins.springsecurity' but must now use 'grails.plugin.springsecurity'" + } + grailsConfig.getProperty('grails.plugin.springsecurity', ConfigObject) + } + + static void setSecurityConfig(ConfigObject c) { + ConfigObject config = new ConfigObject() + config.grails.plugin.springsecurity = c + + PropertySource propertySource = new MapPropertySource('SecurityConfig', [:] << config) + + def propertySources = application.mainContext.environment.propertySources + propertySources.addFirst propertySource + getApplication().config = new PropertySourcesConfig(propertySources) + } + + static List splitMap(List> map) { + map.collect { Map row -> + + List tokens + def value = row.access + if (value instanceof Collection || value.getClass().array) { + tokens = value*.toString() + } else { // String/GString + tokens = [value.toString()] + } + + def httpMethod = row.httpMethod ?: null + if (httpMethod instanceof CharSequence) { + httpMethod = HttpMethod.valueOf(httpMethod) + } + + new InterceptedUrl(row.pattern as String, tokens, httpMethod as HttpMethod) + } + } + + static Collection buildConfigAttributes(Collection tokens, boolean expressions = true) { + Collection configAttributes = [] as Set + + def ctx = getApplication().mainContext + SecurityExpressionHandler expressionHandler = ctx.getBean('webExpressionHandler') + AccessDecisionVoter roleVoter = ctx.getBean('roleVoter') + AccessDecisionVoter authenticatedVoter = ctx.getBean('authenticatedVoter') + + for (String token in tokens) { + ConfigAttribute config = new SecurityConfig(token) + boolean supports = !expressions || token.startsWith('RUN_AS') || token.startsWith('SCOPE') || + supports(config, roleVoter) || supports(config, authenticatedVoter) + if (supports) { + configAttributes << config + } else { + try { + Expression expression = expressionHandler.expressionParser.parseExpression(token) + configAttributes << new WebExpressionConfigAttribute(expression) + } + catch (ParseException e) { + log.error "\nError parsing expression '$token': $e.message\n", e + throw e + } + } + } + + log.trace 'Built ConfigAttributes {} for tokens {}', configAttributes, tokens + configAttributes + } + + static String getGrailsServerURL() { + getApplication().config.grails.serverURL ?: null + } + + private static boolean supports(ConfigAttribute config, AccessDecisionVoter voter) { + voter.supports config + } + + private static lookupPropertyValue(o, String name) { + o."${getConfigProperty(name)}" + } + + private static GrailsApplication getApplication() { + if (!application) { + application = Holders.grailsApplication + } + application + } + + static UrlMappingInfo[] matchAllUrlMappings(UrlMappingsHolder urlMappingsHolder, String requestUrl, + GrailsWebRequest grailsRequest, HttpServletResponseExtension extension) { + String method = grailsRequest.currentRequest.method + String version = grailsRequest.getHeader(ACCEPT_VERSION) ?: extension.getMimeTypeForRequest(grailsRequest).version + urlMappingsHolder.matchAll requestUrl, method, version == null ? UrlMapping.ANY_VERSION : version + } + + static SortedMap findFilterChainNames(ConfigObject conf) { + SpringSecurityUtils.findFilterChainNames conf.filterChain.filterNames, + conf.secureChannel.definition as Boolean ?: false, conf.ipRestrictions as Boolean ?: false, conf.useX509 as Boolean ?: false, + conf.useDigestAuth as Boolean ?: false, conf.useBasicAuth as Boolean ?: false, conf.useSwitchUserFilter as Boolean ?: false + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy new file mode 100644 index 00000000000..292d463ccc6 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluder.groovy @@ -0,0 +1,285 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.plugin.springsecurity + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter +import org.springframework.boot.autoconfigure.AutoConfigurationMetadata +import org.springframework.context.EnvironmentAware +import org.springframework.core.env.Environment + +/** + * Automatically excludes Spring Boot security auto-configuration classes that + * conflict with the Grails Spring Security plugin. + * + *

Why this filter exists

+ * + *

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

+ * + *

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

+ * + *

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

+ * + *

Coexistence with the component-based Spring Security configuration model

+ * + *

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

+ * + *

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

+ * + *

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

+ * + *
    + *
  • {@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 (for example, to delegate the entire servlet security stack to Spring + * Boot instead of the plugin), set the following property in + * {@code application.yml}:

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

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

+ * + *

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

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

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

+ * + *

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

+ * + *
    + *
  • {@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[] + } + + /** + * 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/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityConfigType.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityConfigType.groovy new file mode 100644 index 00000000000..68ad9eba049 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityConfigType.groovy @@ -0,0 +1,34 @@ +/* + * 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 + +/** + * @author Burt Beckwith + */ +enum SecurityConfigType { + + /** Annotations in controllers. */ + Annotation, + + /** Requestmap domain class. */ + Requestmap, + + /** Map defined in Config.groovy. */ + InterceptUrlMap +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy new file mode 100644 index 00000000000..7f6106b85c9 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityEventListener.groovy @@ -0,0 +1,96 @@ +/* + * 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 org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.context.ApplicationEvent +import org.springframework.context.ApplicationListener +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 +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent +import org.springframework.security.web.authentication.switchuser.AuthenticationSwitchUserEvent + +import groovy.transform.CompileStatic + +/** + * Registers as an event listener and delegates handling of security-related events + * to optional closures defined in Config.groovy. + * + * The following callbacks are supported:
+ *
    + *
  • onInteractiveAuthenticationSuccessEvent
  • + *
  • onAbstractAuthenticationFailureEvent
  • + *
  • onAuthenticationSuccessEvent
  • + *
  • onAuthenticationSwitchUserEvent
  • + *
  • onAuthorizationEvent
  • + *
+ * All callbacks are optional; you can implement just the ones you're interested in, e.g. + *
+ * grails {
+ *    plugin {
+ *       springsecurity {
+ *          ...
+ *          onAuthenticationSuccessEvent = { e, appCtx ->
+ *             ...
+ *          }
+ *       }
+ *    }
+ * }
+ * 
+ * The event and the Spring context are provided in case you need to look up a Spring bean, + * e.g. the Hibernate SessionFactory. + * + * @author Burt Beckwith + */ +@CompileStatic +class SecurityEventListener implements ApplicationListener, ApplicationContextAware { + + ApplicationContext applicationContext + + void onApplicationEvent(ApplicationEvent e) { + if (e instanceof AbstractAuthenticationEvent) { + if (e instanceof InteractiveAuthenticationSuccessEvent) { + call e, 'onInteractiveAuthenticationSuccessEvent' + } + else if (e instanceof AbstractAuthenticationFailureEvent) { + call e, 'onAbstractAuthenticationFailureEvent' + } + else if (e instanceof AuthenticationSuccessEvent) { + call e, 'onAuthenticationSuccessEvent' + } + else if (e instanceof AuthenticationSwitchUserEvent) { + call e, 'onAuthenticationSwitchUserEvent' + } + } + else if (e instanceof AuthorizationEvent) { + call e, 'onAuthorizationEvent' + } + } + + @SuppressWarnings('rawtypes') + protected void call(ApplicationEvent e, String closureName) { + def closure = SpringSecurityUtils.securityConfig[closureName] + if (closure instanceof Closure) { + closure e, applicationContext + } + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityFilterPosition.java b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityFilterPosition.java new file mode 100644 index 00000000000..0d76162351a --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SecurityFilterPosition.java @@ -0,0 +1,129 @@ +/* + * 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; + +/** + * Stores the default order numbers of all Spring Security filters for use in configuration. + *

+ * Equivalent to org.springframework.security.config.http.SecurityFilters which + * unfortunately is package-default. + * + */ +public enum SecurityFilterPosition { + + FIRST(Integer.MIN_VALUE), + + DISABLE_ENCODE_URL_FILTER, + + FORCE_EAGER_SESSION_FILTER, + + @Deprecated + CHANNEL_FILTER, + + HTTPS_REDIRECT_FILTER, + + SECURITY_CONTEXT_FILTER, + + CONCURRENT_SESSION_FILTER, + + WEB_ASYNC_MANAGER_FILTER, + + HEADERS_FILTER, + + CORS_FILTER, + + SAML2_LOGOUT_REQUEST_FILTER, + + SAML2_LOGOUT_RESPONSE_FILTER, + + CSRF_FILTER, + + SAML2_LOGOUT_FILTER, + + LOGOUT_FILTER, + + OAUTH2_AUTHORIZATION_REQUEST_FILTER, + + SAML2_AUTHENTICATION_REQUEST_FILTER, + + X509_FILTER, + + PRE_AUTH_FILTER, + + CAS_FILTER, + + OAUTH2_LOGIN_FILTER, + + SAML2_AUTHENTICATION_FILTER, + + FORM_LOGIN_FILTER, + + DEFAULT_RESOURCES_FILTER, + + LOGIN_PAGE_FILTER, + + LOGOUT_PAGE_FILTER, + + DIGEST_AUTH_FILTER, + + BEARER_TOKEN_AUTH_FILTER, + + BASIC_AUTH_FILTER, + + REQUEST_CACHE_FILTER, + + SERVLET_API_SUPPORT_FILTER, + + JAAS_API_SUPPORT_FILTER, + + REMEMBER_ME_FILTER, + + ANONYMOUS_FILTER, + + OAUTH2_AUTHORIZATION_CODE_GRANT_FILTER, + + WELL_KNOWN_CHANGE_PASSWORD_REDIRECT_FILTER, + + SESSION_MANAGEMENT_FILTER, + + EXCEPTION_TRANSLATION_FILTER, + + FILTER_SECURITY_INTERCEPTOR, + + SWITCH_USER_FILTER, + + LAST(Integer.MAX_VALUE); + + private static final int INTERVAL = 100; + + private final int order; + + SecurityFilterPosition() { + this.order = ordinal() * INTERVAL; + } + + SecurityFilterPosition(int order) { + this.order = order; + } + + public int getOrder() { + return this.order; + } + +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy new file mode 100644 index 00000000000..1a4d71559f4 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityBeanFactoryPostProcessor.groovy @@ -0,0 +1,76 @@ +/* + * 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.beans.BeansException +import org.springframework.beans.MutablePropertyValues +import org.springframework.beans.factory.config.BeanFactoryPostProcessor +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory +import org.springframework.beans.factory.config.RuntimeBeanReference +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.GenericBeanDefinition +import org.springframework.boot.web.servlet.FilterRegistrationBean + +/** + * Unregisters auto-config beans registered by Boot. + * + * @author Burt Beckwith + */ +@CompileStatic +class SpringSecurityBeanFactoryPostProcessor implements BeanFactoryPostProcessor { + + 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 { + if (beanFactory instanceof BeanDefinitionRegistry) { + removeAutoconfigBeans beanFactory + disableFilterRegistrationBeans beanFactory + } + } + + protected void removeAutoconfigBeans(BeanDefinitionRegistry beanFactory) { + if (beanFactory.containsBeanDefinition(AUTOCONFIG_NAME)) { + beanFactory.removeBeanDefinition AUTOCONFIG_NAME + } + + if (beanFactory.containsBeanDefinition(SECURITY_PROPERTIES_NAME)) { + if (beanFactory.getBeanDefinition(SECURITY_PROPERTIES_NAME).factoryBeanName == AUTOCONFIG_NAME) { + beanFactory.removeBeanDefinition SECURITY_PROPERTIES_NAME + } + } + } + + /** + * Need to add a FilterRegistrationBean with enabled set to false to prevent Boot from + * registering all of the filters in the filterchains again as regular filters. + */ + protected void disableFilterRegistrationBeans(BeanDefinitionRegistry beanFactory) { + SortedMap filterNames = ReflectionUtils.findFilterChainNames(SpringSecurityUtils.securityConfig) + for (String name in filterNames.values()) { + beanFactory.registerBeanDefinition name + 'DeregistrationBean', new GenericBeanDefinition( + beanClass: FilterRegistrationBean, + propertyValues: new MutablePropertyValues( + enabled: false, + filter: new RuntimeBeanReference(name))) + } + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy new file mode 100644 index 00000000000..a077efd55eb --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityCoreGrailsPlugin.groovy @@ -0,0 +1,1190 @@ +/* + * 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 grails.plugin.springsecurity.access.NullAfterInvocationProvider +import grails.plugin.springsecurity.access.intercept.NullAfterInvocationManager +import grails.plugin.springsecurity.access.vote.AuthenticatedVetoableDecisionManager +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 +import grails.plugin.springsecurity.userdetails.NoStackUsernameNotFoundException +import grails.plugin.springsecurity.web.GrailsRedirectStrategy +import grails.plugin.springsecurity.web.NullFilterChainValidator +import grails.plugin.springsecurity.web.SecurityRequestHolderFilter +import grails.plugin.springsecurity.web.UpdateRequestContextHolderExceptionTranslationFilter +import grails.plugin.springsecurity.web.access.AjaxAwareAccessDeniedHandler +import grails.plugin.springsecurity.web.access.DefaultThrowableAnalyzer +import grails.plugin.springsecurity.web.access.GrailsWebInvocationPrivilegeEvaluator +import grails.plugin.springsecurity.web.access.expression.WebExpressionVoter +import grails.plugin.springsecurity.web.access.intercept.AnnotationFilterInvocationDefinition +import grails.plugin.springsecurity.web.access.intercept.ChannelFilterInvocationSecurityMetadataSourceFactoryBean +import grails.plugin.springsecurity.web.access.intercept.InterceptUrlMapFilterInvocationDefinition +import grails.plugin.springsecurity.web.access.intercept.RequestmapFilterInvocationDefinition +import grails.plugin.springsecurity.web.authentication.AjaxAwareAuthenticationEntryPoint +import grails.plugin.springsecurity.web.authentication.AjaxAwareAuthenticationFailureHandler +import grails.plugin.springsecurity.web.authentication.AjaxAwareAuthenticationSuccessHandler +import grails.plugin.springsecurity.web.authentication.FilterProcessUrlRequestMatcher +import grails.plugin.springsecurity.web.authentication.GrailsUsernamePasswordAuthenticationFilter +import grails.plugin.springsecurity.web.authentication.logout.MutableLogoutFilter +import grails.plugin.springsecurity.web.authentication.preauth.x509.ClosureX509PrincipalExtractor +import grails.plugin.springsecurity.web.authentication.preauth.x509.NullAuthenticationFailureHandler +import grails.plugin.springsecurity.web.authentication.preauth.x509.NullAuthenticationSuccessHandler +import grails.plugin.springsecurity.web.authentication.rememberme.GormPersistentTokenRepository +import grails.plugin.springsecurity.web.authentication.switchuser.NullSwitchUserAuthorityChanger +import grails.plugin.springsecurity.web.filter.DebugFilter +import grails.plugin.springsecurity.web.filter.GrailsAnonymousAuthenticationFilter + +import grails.plugin.springsecurity.web.filter.GrailsRememberMeAuthenticationFilter +import grails.plugin.springsecurity.web.filter.IpAddressFilter +import grails.plugins.Plugin +import groovy.util.logging.Slf4j +import org.grails.web.mime.HttpServletResponseExtension +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.boot.web.servlet.ServletListenerRegistrationBean +import org.springframework.cache.jcache.JCacheCacheManager +import org.springframework.expression.spel.standard.SpelExpressionParser +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.intercept.AfterInvocationProviderManager +import org.springframework.security.access.intercept.NullRunAsManager +import org.springframework.security.access.vote.AuthenticatedVoter +import org.springframework.security.access.vote.RoleHierarchyVoter +import org.springframework.security.authentication.AccountStatusUserDetailsChecker +import org.springframework.security.authentication.AuthenticationTrustResolverImpl +import org.springframework.security.authentication.DefaultAuthenticationEventPublisher +import org.springframework.security.authentication.ProviderManager +import org.springframework.security.authentication.RememberMeAuthenticationProvider +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 +import org.springframework.security.crypto.password.DelegatingPasswordEncoder +import org.springframework.security.crypto.password.LdapShaPasswordEncoder +import org.springframework.security.crypto.password.Md4PasswordEncoder +import org.springframework.security.crypto.password.MessageDigestPasswordEncoder +import org.springframework.security.crypto.password.NoOpPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder +import org.springframework.security.crypto.password.StandardPasswordEncoder +import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder +import org.springframework.security.web.FilterChainProxy +import org.springframework.security.web.PortMapperImpl +import org.springframework.security.web.PortResolverImpl +import org.springframework.security.web.access.AccessDeniedHandlerImpl +import org.springframework.security.web.access.channel.ChannelDecisionManagerImpl +import org.springframework.security.web.access.channel.ChannelProcessingFilter +import org.springframework.security.web.access.channel.InsecureChannelProcessor +import org.springframework.security.web.access.channel.RetryWithHttpEntryPoint +import org.springframework.security.web.access.channel.RetryWithHttpsEntryPoint +import org.springframework.security.web.access.channel.SecureChannelProcessor +import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler +import org.springframework.security.web.access.intercept.FilterSecurityInterceptor +import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider +import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor +import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter +import org.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl +import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices +import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices +import org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy +import org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy +import org.springframework.security.web.authentication.switchuser.SwitchUserFilter +import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter +import org.springframework.security.web.authentication.www.DigestAuthenticationEntryPoint +import org.springframework.security.web.authentication.www.DigestAuthenticationFilter +import org.springframework.security.web.context.HttpSessionSecurityContextRepository +import org.springframework.security.web.context.SecurityContextPersistenceFilter +import org.springframework.security.web.firewall.DefaultHttpFirewall +import org.springframework.security.web.savedrequest.HttpSessionRequestCache +import org.springframework.security.web.savedrequest.NullRequestCache +import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter +import org.springframework.security.web.session.HttpSessionEventPublisher +import org.springframework.security.web.util.matcher.AnyRequestMatcher +import org.springframework.web.filter.FormContentFilter + +import jakarta.servlet.DispatcherType + +/** + * @author Burt Beckwith + */ +@Slf4j +class SpringSecurityCoreGrailsPlugin extends Plugin { + + public static final String ENCODING_ID_BCRYPT = 'bcrypt' + public static final String ENCODING_ID_LDAP = 'ldap' + public static final String ENCODING_ID_MD4 = 'MD4' + public static final String ENCODING_ID_MD5 = 'MD5' + public static final String ENCODING_ID_NOOP = 'noop' + public static final String ENCODING_ID_PBKDF2 = 'pbkdf2' + public static final String ENCODING_ID_SCRYPT = 'scrypt' + public static final String ENCODING_ID_ARGON2 = 'argon2' + public static final String ENCODING_ID_SHA1 = 'SHA-1' + public static final String ENCODING_IDSHA256 = 'SHA-256' + + String grailsVersion = '7.0.0 > *' + List observe = ['controllers'] + List loadAfter = ['controllers', 'hibernate', 'hibernate4', 'hibernate5', 'services'] + String author = 'Burt Beckwith' + String authorEmail = '' + String title = 'Spring Security Core Plugin' + String description = 'Spring Security Core plugin' + String documentation = 'https://apache.github.io/grails-spring-security' + String license = 'APACHE' + def organization = [name: 'Grails', url: 'https://www.grails.org'] + def issueManagement = [url: 'https://github.com/apache/grails-spring-security/issues'] + def scm = [url: 'https://github.com/apache/grails-spring-security'] + def profiles = ['web'] + + private beanTypeResolver + + Closure doWithSpring() { + { -> + ReflectionUtils.application = SpringSecurityUtils.application = grailsApplication + + SpringSecurityUtils.resetSecurityConfig() + ConfigObject conf = SpringSecurityUtils.securityConfig + boolean printStatusMessages = (conf.printStatusMessages instanceof Boolean) ? conf.printStatusMessages : true + if (!conf || !conf.active) { + if (printStatusMessages) { + String message = '\n\nSpring Security is disabled, not loading\n\n' + log.info message + println message + } + return + } + + log.trace 'doWithSpring' + + if (printStatusMessages) { + String message = '\nConfiguring Spring Security Core ...' + log.info message + println message + } + + if (log.traceEnabled) { + def sb = new StringBuilder('Spring Security configuration:\n') + def flatConf = conf.flatten() + for (key in flatConf.keySet().sort()) { + def value = flatConf[key] + sb << '\t' << key << ': ' + if (value instanceof Closure) { + sb << '(closure)' + } + else { + try { + sb << value.toString() // eagerly convert to string to catch individual exceptions + } + catch (e) { + sb << '(an error occurred: ' << e.message << ')' + } + } + sb << '\n' + } + log.trace sb.toString() + } + + def strategyName = conf.sch.strategyName + if (strategyName instanceof CharSequence) { + SCH.strategyName = strategyName.toString() + } + log.trace 'Using SecurityContextHolder strategy {}', SCH.strategyName + + Class beanTypeResolverClass = conf.beanTypeResolverClass ?: BeanTypeResolver + beanTypeResolver = beanTypeResolverClass.newInstance(conf, grailsApplication) + + 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 = filterOrder + } + + if (conf.useHttpSessionEventPublisher) { + log.trace 'Configuring HttpSessionEventPublisher' + httpSessionEventPublisher(classFor('httpSessionEventPublisher', ServletListenerRegistrationBean), new HttpSessionEventPublisher()) + } + + createRefList.delegate = delegate + + /** springSecurityFilterChain */ + configureFilterChain.delegate = delegate + configureFilterChain conf + + // securityRequestHolderFilter + securityRequestHolderFilter(classFor('securityRequestHolderFilter', SecurityRequestHolderFilter)) { + useHeaderCheckChannelSecurity = conf.secureChannel.useHeaderCheckChannelSecurity + secureHeaderName = conf.secureChannel.secureHeaderName // 'X-Forwarded-Proto' + secureHeaderValue = conf.secureChannel.secureHeaderValue // 'http' + insecureHeaderName = conf.secureChannel.insecureHeaderName // 'X-Forwarded-Proto' + insecureHeaderValue = conf.secureChannel.insecureHeaderValue // 'https' + portMapper = ref('portMapper') + portResolver = ref('portResolver') + } + + // logout + configureLogout.delegate = delegate + configureLogout conf + + /** securityContextRepository */ + securityContextRepository(classFor('securityContextRepository', HttpSessionSecurityContextRepository)) { + allowSessionCreation = conf.scr.allowSessionCreation // true + disableUrlRewriting = conf.scr.disableUrlRewriting // true + springSecurityContextKey = conf.scr.springSecurityContextKey // SPRING_SECURITY_CONTEXT + trustResolver = ref('authenticationTrustResolver') + } + + /** securityContextPersistenceFilter */ + securityContextPersistenceFilter(classFor('securityContextPersistenceFilter', SecurityContextPersistenceFilter), ref('securityContextRepository')) { + forceEagerSessionCreation = conf.scpf.forceEagerSessionCreation // false + } + + /** authenticationProcessingFilter */ + configureAuthenticationProcessingFilter.delegate = delegate + configureAuthenticationProcessingFilter conf + + /** securityContextHolderAwareRequestFilter */ + securityContextHolderAwareRequestFilter(classFor('securityContextHolderAwareRequestFilter', SecurityContextHolderAwareRequestFilter)) { + authenticationEntryPoint = ref('authenticationEntryPoint') + authenticationManager = ref('authenticationManager') + logoutHandlers = ref('logoutHandlers') + trustResolver = ref('authenticationTrustResolver') + } + + /** rememberMeAuthenticationFilter */ + rememberMeAuthenticationFilter(classFor('rememberMeAuthenticationFilter', GrailsRememberMeAuthenticationFilter), + ref('authenticationManager'), ref('rememberMeServices'), ref('requestCache')) { + authenticationSuccessHandler = ref('authenticationSuccessHandler') + createSessionOnSuccess = conf.rememberMe.createSessionOnSuccess // true + } + + userDetailsChecker(classFor('userDetailsChecker', AccountStatusUserDetailsChecker)) + + authoritiesMapper(classFor('authoritiesMapper', RoleHierarchyAuthoritiesMapper), ref('roleHierarchy')) + + /** rememberMeServices */ + if (conf.rememberMe.persistent) { + log.trace 'Configuring persistent remember-me' + rememberMeServices(classFor('rememberMeServices', PersistentTokenBasedRememberMeServices), + conf.rememberMe.key, ref('userDetailsService'), ref('tokenRepository')) { + cookieName = conf.rememberMe.cookieName + if (conf.rememberMe.cookieDomain) { + cookieDomain = conf.rememberMe.cookieDomain + } + alwaysRemember = conf.rememberMe.alwaysRemember + tokenValiditySeconds = conf.rememberMe.tokenValiditySeconds + parameter = conf.rememberMe.parameter + if (conf.rememberMe.useSecureCookie instanceof Boolean) { + useSecureCookie = conf.rememberMe.useSecureCookie // null + } + authenticationDetailsSource = ref('authenticationDetailsSource') + userDetailsChecker = ref('userDetailsChecker') + authoritiesMapper = ref('authoritiesMapper') + + seriesLength = conf.rememberMe.persistentToken.seriesLength // 16 + tokenLength = conf.rememberMe.persistentToken.tokenLength // 16 + } + + tokenRepository(classFor('tokenRepository', GormPersistentTokenRepository)) + } + else { + log.trace 'Configuring non-persistent remember-me' + rememberMeServices(classFor('rememberMeServices', TokenBasedRememberMeServices), conf.rememberMe.key, ref('userDetailsService')) { + cookieName = conf.rememberMe.cookieName + if (conf.rememberMe.cookieDomain) { + cookieDomain = conf.rememberMe.cookieDomain + } + alwaysRemember = conf.rememberMe.alwaysRemember + tokenValiditySeconds = conf.rememberMe.tokenValiditySeconds + parameter = conf.rememberMe.parameter + if (conf.rememberMe.useSecureCookie instanceof Boolean) { + useSecureCookie = conf.rememberMe.useSecureCookie // null + } + authenticationDetailsSource = ref('authenticationDetailsSource') + userDetailsChecker = ref('userDetailsChecker') + authoritiesMapper = ref('authoritiesMapper') + } + + // register a lightweight impl so there's a bean in either case + tokenRepository(classFor('tokenRepository', InMemoryTokenRepositoryImpl)) + } + + /** anonymousAuthenticationFilter */ + anonymousAuthenticationFilter(classFor('anonymousAuthenticationFilter', GrailsAnonymousAuthenticationFilter)) { + authenticationDetailsSource = ref('authenticationDetailsSource') + key = conf.anon.key + } + + throwableAnalyzer(classFor('throwableAnalyzer', DefaultThrowableAnalyzer)) + + /** exceptionTranslationFilter */ + exceptionTranslationFilter(classFor('exceptionTranslationFilter', UpdateRequestContextHolderExceptionTranslationFilter), + ref('authenticationEntryPoint'), ref('requestCache')) { + accessDeniedHandler = ref('accessDeniedHandler') + authenticationTrustResolver = ref('authenticationTrustResolver') + throwableAnalyzer = ref('throwableAnalyzer') + } + accessDeniedHandler(classFor('accessDeniedHandler', AjaxAwareAccessDeniedHandler)) { + errorPage = conf.adh.errorPage == 'null' ? null : conf.adh.errorPage // '/login/denied' or 403 + ajaxErrorPage = conf.adh.ajaxErrorPage + useForward = conf.adh.useForward + portResolver = ref('portResolver') + authenticationTrustResolver = ref('authenticationTrustResolver') + requestCache = ref('requestCache') + } + + /** authenticationTrustResolver */ + authenticationTrustResolver(classFor('authenticationTrustResolver', AuthenticationTrustResolverImpl)) { + anonymousClass = conf.atr.anonymousClass + rememberMeClass = conf.atr.rememberMeClass + } + + // default 'authenticationEntryPoint' + authenticationEntryPoint(classFor('authenticationEntryPoint', AjaxAwareAuthenticationEntryPoint), conf.auth.loginFormUrl) { // '/login/auth' + ajaxLoginFormUrl = conf.auth.ajaxLoginFormUrl // '/login/authAjax' + forceHttps = conf.auth.forceHttps // false + useForward = conf.auth.useForward // false + portMapper = ref('portMapper') + redirectStrategy = ref('redirectStrategy') + } + + /** filterInvocationInterceptor */ + + // TODO doc new + if (conf.afterInvocationManagerProviderNames) { + log.trace 'Configuring AfterInvocationProviderManager' + afterInvocationManager(classFor('afterInvocationManager', AfterInvocationProviderManager)) { + providers = [new NullAfterInvocationProvider()] // will be replaced in doWithApplicationContext + } + } + else { + // register a lightweight impl so there's a bean in either case + afterInvocationManager(classFor('afterInvocationManager', NullAfterInvocationManager)) + } + + filterInvocationInterceptor(classFor('filterInvocationInterceptor', FilterSecurityInterceptor)) { + authenticationManager = ref('authenticationManager') + accessDecisionManager = ref('accessDecisionManager') + securityMetadataSource = ref('objectDefinitionSource') + runAsManager = ref('runAsManager') + afterInvocationManager = ref('afterInvocationManager') + alwaysReauthenticate = conf.fii.alwaysReauthenticate // false + rejectPublicInvocations = conf.fii.rejectPublicInvocations // true + validateConfigAttributes = conf.fii.validateConfigAttributes // true + publishAuthorizationSuccess = conf.fii.publishAuthorizationSuccess // false + observeOncePerRequest = conf.fii.observeOncePerRequest // true + } + + String securityConfigType = SpringSecurityUtils.securityConfigType + log.trace "Using security config type '{}'", securityConfigType + if (securityConfigType != 'Annotation' && + securityConfigType != 'Requestmap' && + securityConfigType != 'InterceptUrlMap') { + + String message = """ + ERROR: the 'securityConfigType' property must be one of + 'Annotation', 'Requestmap', or 'InterceptUrlMap' or left unspecified + to default to 'Annotation'; setting value to 'Annotation' + """ + println message + log.warn message + + securityConfigType = 'Annotation' + } + + httpServletResponseExtension(classFor('httpServletResponseExtension', HttpServletResponseExtension)) // used to be responseMimeTypesApi + + if (securityConfigType == 'Annotation') { + objectDefinitionSource(classFor('objectDefinitionSource', AnnotationFilterInvocationDefinition)) { + application = grailsApplication + grailsUrlConverter = ref('grailsUrlConverter') + httpServletResponseExtension = ref('httpServletResponseExtension') + if (conf.rejectIfNoRule instanceof Boolean) { + rejectIfNoRule = conf.rejectIfNoRule + } + } + } + else if (securityConfigType == 'Requestmap') { + objectDefinitionSource(classFor('objectDefinitionSource', RequestmapFilterInvocationDefinition)) { + if (conf.rejectIfNoRule instanceof Boolean) { + rejectIfNoRule = conf.rejectIfNoRule + } + } + } + else if (securityConfigType == 'InterceptUrlMap') { + objectDefinitionSource(classFor('objectDefinitionSource', InterceptUrlMapFilterInvocationDefinition)) { + if (conf.rejectIfNoRule instanceof Boolean) { + rejectIfNoRule = conf.rejectIfNoRule + } + } + } + + webInvocationPrivilegeEvaluator(classFor('webInvocationPrivilegeEvaluator', GrailsWebInvocationPrivilegeEvaluator), ref('filterInvocationInterceptor')) + + // voters + configureVoters.delegate = delegate + configureVoters conf + + /** anonymousAuthenticationProvider */ + anonymousAuthenticationProvider(classFor('anonymousAuthenticationProvider', GrailsAnonymousAuthenticationProvider)) + + /** rememberMeAuthenticationProvider */ + rememberMeAuthenticationProvider(classFor('rememberMeAuthenticationProvider', RememberMeAuthenticationProvider), conf.rememberMe.key) + + // authenticationManager + configureAuthenticationManager.delegate = delegate + configureAuthenticationManager conf + + /** daoAuthenticationProvider */ + preAuthenticationChecks(classFor('preAuthenticationChecks', DefaultPreAuthenticationChecks)) + postAuthenticationChecks(classFor('postAuthenticationChecks', DefaultPostAuthenticationChecks)) + + daoAuthenticationProvider(classFor('daoAuthenticationProvider', DaoAuthenticationProvider), ref('userDetailsService')) { + passwordEncoder = ref('passwordEncoder') + userCache = ref('userCache') + preAuthenticationChecks = ref('preAuthenticationChecks') + postAuthenticationChecks = ref('postAuthenticationChecks') + authoritiesMapper = ref('authoritiesMapper') + hideUserNotFoundExceptions = conf.dao.hideUserNotFoundExceptions // true + } + + /** passwordEncoder */ + String algorithm = conf.password.algorithm + + log.trace 'Using {} algorithm', algorithm + passwordEncoder(classFor('passwordEncoder', DelegatingPasswordEncoder), algorithm, idToPasswordEncoder(conf)) + + /** userDetailsService */ + userDetailsService(classFor('userDetailsService', GormUserDetailsService)) { + grailsApplication = grailsApplication + } + + /** authenticationUserDetailsService */ + authenticationUserDetailsService(classFor('authenticationUserDetailsService', UserDetailsByNameServiceWrapper), ref('userDetailsService')) + + // port mappings for channel security, etc. + portMapper(classFor('portMapper', PortMapperImpl)) { + portMappings = [(conf.portMapper.httpPort.toString()): conf.portMapper.httpsPort.toString()] // 8080, 8443 + + } + portResolver(classFor('portResolver', PortResolverImpl)) { + portMapper = ref('portMapper') + } + + // SecurityEventListener + if (conf.useSecurityEventListener) { + log.trace 'Configuring SecurityEventListener' + securityEventListener(classFor('authenticationEventPublisher', SecurityEventListener)) + + authenticationEventPublisher(classFor('authenticationEventPublisher', DefaultAuthenticationEventPublisher)) { + additionalExceptionMappings = + ([(NoStackUsernameNotFoundException.name): AuthenticationFailureBadCredentialsEvent.name] as Properties) + } + } + else { + authenticationEventPublisher(classFor('authenticationEventPublisher', NullAuthenticationEventPublisher)) + } + + // Basic Auth + if (conf.useBasicAuth) { + log.trace 'Configuring Basic auth' + configureBasicAuth.delegate = delegate + configureBasicAuth conf + } + + // Digest Auth + if (conf.useDigestAuth) { + log.trace 'Configuring Digest auth' + configureDigestAuth.delegate = delegate + configureDigestAuth conf + } + + // Switch User + if (conf.useSwitchUserFilter) { + + log.trace 'Configuring SwitchUserFilter' + + // TODO doc new + switchUserAuthorityChanger(classFor('switchUserAuthorityChanger', NullSwitchUserAuthorityChanger)) + + switchUserProcessingFilter(classFor('switchUserProcessingFilter', SwitchUserFilter)) { + userDetailsService = ref('userDetailsService') + userDetailsChecker = ref('userDetailsChecker') + authenticationDetailsSource = ref('authenticationDetailsSource') + switchUserAuthorityChanger = ref('switchUserAuthorityChanger') + switchUserUrl = conf.switchUser.switchUserUrl // '/login/impersonate' + exitUserUrl = conf.switchUser.exitUserUrl // '/logout/impersonate' + usernameParameter = conf.switchUser.usernameParameter // 'username' + if (conf.switchUser.targetUrl) { + targetUrl = conf.switchUser.targetUrl + } + else { + successHandler = ref('authenticationSuccessHandler') + } + if (conf.switchUser.switchFailureUrl) { + switchFailureUrl = conf.switchUser.switchFailureUrl + } + else { + failureHandler = ref('authenticationFailureHandler') + } + if (conf.switchUser.switchUserMatcher) { + switchUserMatcher = conf.switchUser.switchUserMatcher + } + if (conf.switchUser.exitUserMatcher) { + exitUserMatcher = conf.switchUser.exitUserMatcher + } + } + } + + // per-method run-as, defined here so it can be overridden + runAsManager(classFor('runAsManager', NullRunAsManager)) + + // X.509 + if (conf.useX509) { + log.trace 'Configuring X.509' + configureX509.delegate = delegate + configureX509 conf + } + + // channel (http/https) security + if (conf.secureChannel.definition) { + log.trace 'Configuring channel security' + configureChannelProcessingFilter.delegate = delegate + configureChannelProcessingFilter conf + } + + // IP filter + if (conf.ipRestrictions) { + log.trace 'Configuring IP restrictions' + configureIpFilter.delegate = delegate + configureIpFilter conf + } + + // user details cache + if (conf.cacheUsers) { + log.trace 'Configuring user cache' + userCache(classFor('userCache', SpringUserCacheFactoryBean)) { + cacheManager = ref('cacheManager') + cacheName = 'userCache' + } + cacheManager(classFor('cacheManager', JCacheCacheManager)) + } + else { + userCache(classFor('userCache', NullUserCache)) + } + + /** loggerListener */ + if (conf.registerLoggerListener) { + log.trace 'Register LoggerListener' + loggerListener(classFor('loggerListener', LoggerListener)) + } + + if (conf.debug.useFilter) { + log.trace 'Register DebugFilter' + securityDebugFilter(classFor('securityDebugFilter', DebugFilter), ref('springSecurityFilterChain')) + } + + permissionEvaluator(classFor('permissionEvaluator', DenyAllPermissionEvaluator)) + + if (printStatusMessages) { + String message = '... finished configuring Spring Security Core\n' + log.info message + println message + } + + formContentFilter(classFor('formContentFilter', FormContentFilter)) + } + } + + void doWithApplicationContext() { + ReflectionUtils.application = grailsApplication + + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active) { + return + } + + log.trace 'doWithApplicationContext' + + if (SpringSecurityUtils.securityConfigType == 'Annotation') { + initializeFromAnnotations conf + } + + /** + * Specify the field of the role hierarchy bean + * if the role hierarchy is backed by a domain object use this instead of roleHierarchy config param + * @author fpape + */ + String roleHierarchy + if (conf.roleHierarchyEntryClassName) { + log.trace 'Loading persistent role hierarchy' + Class roleHierarchyEntryClass = Class.forName(conf.roleHierarchyEntryClassName) + roleHierarchyEntryClass.withTransaction { + roleHierarchy = roleHierarchyEntryClass.list()*.entry.join('\n') + } + } + else { + roleHierarchy = conf.roleHierarchy + } + + applicationContext.roleHierarchy.hierarchy = roleHierarchy + + // build filters here to give dependent plugins a chance to register some + SortedMap filterNames = ReflectionUtils.findFilterChainNames(conf) + def securityFilterChains = applicationContext.securityFilterChains + + // if sitemesh 3 is installed, the filter should be applied a second time + // as part of the security filter chain so that pages are decorated using the security context + if (applicationContext.containsBean('sitemesh')) { + filterNames[SecurityFilterPosition.EXCEPTION_TRANSLATION_FILTER.order - 4] = 'sitemesh' + } + + SpringSecurityUtils.buildFilterChains filterNames, conf.filterChain.chainMap ?: [], securityFilterChains, applicationContext + log.trace 'Filter chain: {}', securityFilterChains + + // build voters list here to give dependent plugins a chance to register some + def voterNames = conf.voterNames ?: SpringSecurityUtils.voterNames + def decisionVoters = applicationContext.accessDecisionManager.decisionVoters + decisionVoters.clear() + decisionVoters.addAll createBeanList(voterNames) + log.trace 'AccessDecisionVoters: {}', decisionVoters + + // build providers list here to give dependent plugins a chance to register some + def providerNames = [] + if (conf.providerNames) { + providerNames.addAll conf.providerNames + } + else { + providerNames.addAll SpringSecurityUtils.providerNames + if (conf.useX509) { + providerNames << 'x509AuthenticationProvider' + } + } + 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 ?: []) + applicationContext.logoutHandlers.clear() + applicationContext.logoutHandlers.addAll createBeanList(logoutHandlerNames) + log.trace 'LogoutHandlers: {}', applicationContext.logoutHandlers + + // build after-invocation provider names here to give dependent plugins a chance to register some + def afterInvocationManagerProviderNames = conf.afterInvocationManagerProviderNames ?: SpringSecurityUtils.afterInvocationManagerProviderNames + if (afterInvocationManagerProviderNames) { + applicationContext.afterInvocationManager.providers = createBeanList(afterInvocationManagerProviderNames) + log.trace 'AfterInvocationProviders: {}', applicationContext.afterInvocationManager.providers + } + + if (conf.debug.useFilter) { + applicationContext.removeAlias 'springSecurityFilterChain' + applicationContext.registerAlias 'securityDebugFilter', 'springSecurityFilterChain' + } + + if (conf.useDigestAuth) { + def passwordEncoder = applicationContext.passwordEncoder +// TODO if (passwordEncoder instanceof DigestAuthPasswordEncoder) { +// passwordEncoder.resetInitializing() +// } + } + } + + void onChange(Map event) { + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active) { + return + } + + if (event.source && grailsApplication.isControllerClass(event.source)) { + + log.trace 'onChange for controller {}', event.source.name + + if (SpringSecurityUtils.securityConfigType == 'Annotation') { + initializeFromAnnotations conf + } + } + } + + void onConfigChange(Map event) { + SpringSecurityUtils.resetSecurityConfig() + + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active) { + return + } + + log.trace 'onConfigChange' + + if (SpringSecurityUtils.securityConfigType == 'Annotation') { + // might have changed controllerAnnotations.staticRules + initializeFromAnnotations conf + } + else if (SpringSecurityUtils.securityConfigType == 'InterceptUrlMap') { + event.ctx.objectDefinitionSource.reset() + } + } + + private void initializeFromAnnotations(conf) { + AnnotationFilterInvocationDefinition afid = applicationContext.objectDefinitionSource + afid.initialize conf.controllerAnnotations.staticRules, + applicationContext.grailsUrlMappingsHolder, grailsApplication.controllerClasses, + grailsApplication.domainClasses + } + + private createRefList = { names -> names.collect { name -> ref(name) } } + + 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)) { + clearAuthentication = conf.logout.clearAuthentication // true + invalidateHttpSession = conf.logout.invalidateHttpSession // true + } + + // create an initially empty list here, will be populated in doWithApplicationContext + logoutHandlers(classFor('logoutHandlers', ArrayList)) + + logoutSuccessHandler(classFor('logoutSuccessHandler', SimpleUrlLogoutSuccessHandler)) { + redirectStrategy = ref('redirectStrategy') + defaultTargetUrl = conf.logout.afterLogoutUrl // '/' + alwaysUseDefaultTargetUrl = conf.logout.alwaysUseDefaultTargetUrl // false + targetUrlParameter = conf.logout.targetUrlParameter // null + useReferer = conf.logout.redirectToReferer // false + } + + /** logoutFilter */ + logoutFilter(classFor('logoutFilter', MutableLogoutFilter), ref('logoutSuccessHandler')) { + filterProcessesUrl = conf.logout.filterProcessesUrl // '/logoff' + handlers = ref('logoutHandlers') + } + } + + private configureBasicAuth = { conf -> + + basicAuthenticationEntryPoint(classFor('basicAuthenticationEntryPoint', BasicAuthenticationEntryPoint)) { + realmName = conf.basic.realmName // 'Grails Realm' + } + + basicAuthenticationFilter(classFor('basicAuthenticationFilter', BasicAuthenticationFilter), + ref('authenticationManager'), ref('basicAuthenticationEntryPoint')) { + authenticationDetailsSource = ref('authenticationDetailsSource') + rememberMeServices = ref('rememberMeServices') + credentialsCharset = conf.basic.credentialsCharset // 'UTF-8' + } + + basicAccessDeniedHandler(classFor('basicAccessDeniedHandler', AccessDeniedHandlerImpl)) + + basicRequestCache(classFor('basicRequestCache', NullRequestCache)) + + basicExceptionTranslationFilter(classFor('basicExceptionTranslationFilter', UpdateRequestContextHolderExceptionTranslationFilter), + ref('basicAuthenticationEntryPoint'), ref('basicRequestCache')) { + accessDeniedHandler = ref('basicAccessDeniedHandler') + authenticationTrustResolver = ref('authenticationTrustResolver') + throwableAnalyzer = ref('throwableAnalyzer') + } + } + + private configureDigestAuth = { conf -> + + if (conf.digest.useCleartextPasswords) { + passwordEncoder(classFor('passwordEncoder', DelegatingPasswordEncoder), ENCODING_ID_NOOP, idToPasswordEncoder(conf)) + } + else { + conf.digest.passwordAlreadyEncoded = true + conf.dao.reflectionSaltSourceProperty = conf.userLookup.usernamePropertyName + passwordEncoder(classFor('passwordEncoder', DigestAuthPasswordEncoder)) { + realm = conf.digest.realmName + } + } + + digestAuthenticationEntryPoint(classFor('digestAuthenticationEntryPoint', DigestAuthenticationEntryPoint)) { + realmName = conf.digest.realmName // 'Grails Realm' + key = conf.digest.key // 'changeme' + nonceValiditySeconds = conf.digest.nonceValiditySeconds // 300 + } + + digestAuthenticationFilter(classFor('digestAuthenticationFilter', DigestAuthenticationFilter)) { + authenticationDetailsSource = ref('authenticationDetailsSource') + authenticationEntryPoint = ref('digestAuthenticationEntryPoint') + userCache = ref('userCache') + userDetailsService = ref('userDetailsService') + passwordAlreadyEncoded = conf.digest.passwordAlreadyEncoded // false + createAuthenticatedToken = conf.digest.createAuthenticatedToken // false + } + + digestAccessDeniedHandler(classFor('digestAccessDeniedHandler', AccessDeniedHandlerImpl)) + + digestExceptionTranslationFilter(classFor('digestExceptionTranslationFilter', UpdateRequestContextHolderExceptionTranslationFilter), + ref('digestAuthenticationEntryPoint'), ref('requestCache')) { + accessDeniedHandler = ref('digestAccessDeniedHandler') + authenticationTrustResolver = ref('authenticationTrustResolver') + throwableAnalyzer = ref('throwableAnalyzer') + } + } + + private configureVoters = { conf -> + + // the hierarchy string is set in doWithApplicationContext to support building + // from the database using GORM if roleHierarchyEntryClassName is set + roleHierarchy(classFor('roleHierarchy', MutableRoleHierarchy)) + + roleVoter(classFor('roleVoter', RoleHierarchyVoter), ref('roleHierarchy')) + + authenticatedVoter(classFor('authenticatedVoter', AuthenticatedVoter)) { + authenticationTrustResolver = ref('authenticationTrustResolver') + } + + voterExpressionParser(classFor('voterExpressionParser', SpelExpressionParser)) + + webExpressionHandler(classFor('webExpressionHandler', DefaultWebSecurityExpressionHandler)) { + expressionParser = ref('voterExpressionParser') + permissionEvaluator = ref('permissionEvaluator') + roleHierarchy = ref('roleHierarchy') + trustResolver = ref('authenticationTrustResolver') + } + + webExpressionVoter(classFor('webExpressionVoter', WebExpressionVoter)) { + expressionHandler = ref('webExpressionHandler') + } + + closureVoter(classFor('closureVoter', ClosureVoter)) + + // create the default list here, will be replaced in doWithApplicationContext + def voters = createRefList(SpringSecurityUtils.voterNames) + + /** accessDecisionManager */ + accessDecisionManager(classFor('accessDecisionManager', AuthenticatedVetoableDecisionManager), voters) { + allowIfAllAbstainDecisions = false + } + } + + private configureAuthenticationManager = { conf -> + + // create the default list here, will be replaced in doWithApplicationContext + def providerRefs = createRefList(SpringSecurityUtils.providerNames) + + /** authenticationManager */ + authenticationManager(classFor('authenticationManager', ProviderManager), providerRefs) { + authenticationEventPublisher = ref('authenticationEventPublisher') + eraseCredentialsAfterAuthentication = conf.providerManager.eraseCredentialsAfterAuthentication // true + } + } + + private configureFilterChain = { conf -> + + filterChainValidator(classFor('filterChainValidator', NullFilterChainValidator)) + + httpFirewall(classFor('httpFirewall', DefaultHttpFirewall)) + + securityFilterChains(classFor('securityFilterChains', ArrayList)) + + springSecurityFilterChainProxy(classFor('springSecurityFilterChainProxy', FilterChainProxy), ref('securityFilterChains')) { + filterChainValidator = ref('filterChainValidator') + firewall = ref('httpFirewall') + } + + springConfig.addAlias 'springSecurityFilterChain', 'springSecurityFilterChainProxy' + } + + private configureChannelProcessingFilter = { conf -> + + retryWithHttpEntryPoint(classFor('retryWithHttpEntryPoint', RetryWithHttpEntryPoint)) { + portMapper = ref('portMapper') + portResolver = ref('portResolver') + redirectStrategy = ref('redirectStrategy') + } + + retryWithHttpsEntryPoint(classFor('retryWithHttpsEntryPoint', RetryWithHttpsEntryPoint)) { + portMapper = ref('portMapper') + portResolver = ref('portResolver') + redirectStrategy = ref('redirectStrategy') + } + + secureChannelProcessor(classFor('secureChannelProcessor', SecureChannelProcessor)) { + entryPoint = ref('retryWithHttpsEntryPoint') + secureKeyword = conf.secureChannel.secureConfigAttributeKeyword // 'REQUIRES_SECURE_CHANNEL' + } + + insecureChannelProcessor(classFor('insecureChannelProcessor', InsecureChannelProcessor)) { + entryPoint = ref('retryWithHttpEntryPoint') + insecureKeyword = conf.secureChannel.insecureConfigAttributeKeyword // 'REQUIRES_INSECURE_CHANNEL' + } + + channelDecisionManager(classFor('channelDecisionManager', ChannelDecisionManagerImpl)) { + channelProcessors = [ref('insecureChannelProcessor'), ref('secureChannelProcessor')] + } + + if (conf.secureChannel.definition instanceof Map) { + throw new IllegalArgumentException('secureChannel.definition defined as a Map is not supported; must be specified as a ' + + "List of Maps as described in section 'Channel Security' of the reference documentation") + } + channelFilterInvocationSecurityMetadataSource(classFor('channelFilterInvocationSecurityMetadataSource', ChannelFilterInvocationSecurityMetadataSourceFactoryBean)) { + definition = conf.secureChannel.definition + } + channelProcessingFilter(classFor('channelProcessingFilter', ChannelProcessingFilter)) { + channelDecisionManager = ref('channelDecisionManager') + securityMetadataSource = ref('channelFilterInvocationSecurityMetadataSource') + } + } + + private configureIpFilter = { conf -> + + if (conf.ipRestrictions instanceof Map) { + throw new IllegalArgumentException('ipRestrictions defined as a Map is not supported; must be specified as a ' + + "List of Maps as described in section 'IP Address Restrictions' of the reference documentation") + } + + if (!(conf.ipRestrictions instanceof List)) { + return + } + + ipAddressFilter(classFor('ipAddressFilter', IpAddressFilter)) { + ipRestrictions = conf.ipRestrictions + } + } + + private configureAuthenticationProcessingFilter = { conf -> + + // TODO create plugin version that overrides extractAttributes + if (conf.useSessionFixationPrevention) { + log.trace 'Configuring session fixation prevention' + sessionAuthenticationStrategy(classFor('sessionAuthenticationStrategy', SessionFixationProtectionStrategy)) { + migrateSessionAttributes = conf.sessionFixationPrevention.migrate // true + alwaysCreateSession = conf.sessionFixationPrevention.alwaysCreateSession // false + } + } + else { + sessionAuthenticationStrategy(classFor('sessionAuthenticationStrategy', NullAuthenticatedSessionStrategy)) + } + + if (conf.failureHandler.exceptionMappings instanceof Map) { + throw new IllegalArgumentException('failureHandler.exceptionMappings defined as a Map is not supported; ' + + '''must be specified as a List of Maps, e.g. +[ + [exception: 'org.springframework.security.authentication.LockedException', url: '/user/accountLocked'], + [exception: 'org.springframework.security.authentication.DisabledException', url: '/user/accountDisabled'], + [exception: 'org.springframework.security.authentication.AccountExpiredException', url: '/user/accountExpired'], + [exception: 'org.springframework.security.authentication.CredentialsExpiredException', url: '/user/passwordExpired'] +] +''') + } + authenticationFailureHandler(classFor('authenticationFailureHandler', AjaxAwareAuthenticationFailureHandler)) { + redirectStrategy = ref('redirectStrategy') + defaultFailureUrl = conf.failureHandler.defaultFailureUrl //'/login/authfail?login_error=1' + useForward = conf.failureHandler.useForward // false + ajaxAuthenticationFailureUrl = conf.failureHandler.ajaxAuthFailUrl // '/login/authfail?ajax=true' + exceptionMappingsList = conf.failureHandler.exceptionMappings // [] + allowSessionCreation = conf.failureHandler.allowSessionCreation // true + } + + filterProcessUrlRequestMatcher(classFor('filterProcessUrlRequestMatcher', FilterProcessUrlRequestMatcher), conf.apf.filterProcessesUrl) // '/login/authenticate' + + authenticationProcessingFilter(classFor('authenticationProcessingFilter', GrailsUsernamePasswordAuthenticationFilter)) { + authenticationManager = ref('authenticationManager') + sessionAuthenticationStrategy = ref('sessionAuthenticationStrategy') + authenticationSuccessHandler = ref('authenticationSuccessHandler') + authenticationFailureHandler = ref('authenticationFailureHandler') + rememberMeServices = ref('rememberMeServices') + authenticationDetailsSource = ref('authenticationDetailsSource') + requiresAuthenticationRequestMatcher = ref('filterProcessUrlRequestMatcher') + usernameParameter = conf.apf.usernameParameter // username + passwordParameter = conf.apf.passwordParameter // password + continueChainBeforeSuccessfulAuthentication = conf.apf.continueChainBeforeSuccessfulAuthentication // false + allowSessionCreation = conf.apf.allowSessionCreation // true + postOnly = conf.apf.postOnly // true + storeLastUsername = conf.apf.storeLastUsername // false + } + + authenticationDetailsSource(classFor('authenticationDetailsSource', WebAuthenticationDetailsSource)) + + requestMatcher(classFor('requestMatcher', AnyRequestMatcher)) + + requestCache(classFor('requestCache', HttpSessionRequestCache)) { + createSessionAllowed = conf.requestCache.createSession // true + requestMatcher = ref('requestMatcher') + } + + authenticationSuccessHandler(classFor('authenticationSuccessHandler', AjaxAwareAuthenticationSuccessHandler)) { + requestCache = ref('requestCache') + defaultTargetUrl = conf.successHandler.defaultTargetUrl // '/' + alwaysUseDefaultTargetUrl = conf.successHandler.alwaysUseDefault // false + targetUrlParameter = conf.successHandler.targetUrlParameter // 'spring-security-redirect' + ajaxSuccessUrl = conf.successHandler.ajaxSuccessUrl // '/login/ajaxSuccess' + useReferer = conf.successHandler.useReferer // false + redirectStrategy = ref('redirectStrategy') + } + + redirectStrategy(classFor('redirectStrategy', GrailsRedirectStrategy)) { + useHeaderCheckChannelSecurity = conf.secureChannel.useHeaderCheckChannelSecurity // false + portResolver = ref('portResolver') + } + } + + private configureX509 = { conf -> + + x509AuthenticationFailureHandler(NullAuthenticationFailureHandler) + x509AuthenticationSuccessHandler(NullAuthenticationSuccessHandler) + + x509ProcessingFilter(classFor('x509ProcessingFilter', X509AuthenticationFilter)) { + authenticationDetailsSource = ref('authenticationDetailsSource') + authenticationFailureHandler = ref('x509AuthenticationFailureHandler') + authenticationManager = ref('authenticationManager') + authenticationSuccessHandler = ref('x509AuthenticationSuccessHandler') + principalExtractor = ref('x509PrincipalExtractor') + checkForPrincipalChanges = conf.x509.checkForPrincipalChanges // false + continueFilterChainOnUnsuccessfulAuthentication = conf.x509.continueFilterChainOnUnsuccessfulAuthentication // true + invalidateSessionOnPrincipalChange = conf.x509.invalidateSessionOnPrincipalChange // true + } + + if (conf.x509.subjectDnClosure) { + x509PrincipalExtractor(classFor('x509PrincipalExtractor', ClosureX509PrincipalExtractor)) + } + else { + x509PrincipalExtractor(classFor('x509PrincipalExtractor', SubjectDnX509PrincipalExtractor)) { + subjectDnRegex = conf.x509.subjectDnRegex // 'CN=(.*?)(?:,|$)' + } + } + + x509AuthenticationProvider(classFor('x509AuthenticationProvider', PreAuthenticatedAuthenticationProvider)) { + preAuthenticatedUserDetailsService = ref('authenticationUserDetailsService') + userDetailsChecker = ref('userDetailsChecker') + throwExceptionWhenTokenRejected = conf.x509.throwExceptionWhenTokenRejected // false + } + + authenticationEntryPoint(classFor('authenticationEntryPoint', Http403ForbiddenEntryPoint)) + } + + private Class classFor(String beanName, Class defaultType) { + 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) { + + MessageDigestPasswordEncoder messageDigestPasswordEncoderMD5 = new MessageDigestPasswordEncoder(ENCODING_ID_MD5) + messageDigestPasswordEncoderMD5.encodeHashAsBase64 = conf.password.encodeHashAsBase64 // false + messageDigestPasswordEncoderMD5.iterations = conf.password.hash.iterations // 10000 + + MessageDigestPasswordEncoder messageDigestPasswordEncoderSHA1 = new MessageDigestPasswordEncoder(ENCODING_ID_SHA1) + messageDigestPasswordEncoderSHA1.encodeHashAsBase64 = conf.password.encodeHashAsBase64 // false + messageDigestPasswordEncoderSHA1.iterations = conf.password.hash.iterations // 10000 + + MessageDigestPasswordEncoder messageDigestPasswordEncoderSHA256 = new MessageDigestPasswordEncoder(ENCODING_IDSHA256) + messageDigestPasswordEncoderSHA256.encodeHashAsBase64 = conf.password.encodeHashAsBase64 // false + messageDigestPasswordEncoderSHA256.iterations = conf.password.hash.iterations // 10000 + + int strength = conf.password.bcrypt.logrounds + [(ENCODING_ID_BCRYPT): new BCryptPasswordEncoder(strength), + (ENCODING_ID_LDAP): new LdapShaPasswordEncoder(), + (ENCODING_ID_MD4): new Md4PasswordEncoder(), + (ENCODING_ID_MD5): messageDigestPasswordEncoderMD5, + (ENCODING_ID_NOOP): NoOpPasswordEncoder.getInstance(), + (ENCODING_ID_PBKDF2): Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8(), + (ENCODING_ID_SCRYPT): SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8(), + (ENCODING_ID_ARGON2): Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(), + (ENCODING_ID_SHA1): messageDigestPasswordEncoderSHA1, + (ENCODING_IDSHA256): messageDigestPasswordEncoderSHA256, + 'sha256': new StandardPasswordEncoder()] + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityUtils.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityUtils.groovy new file mode 100644 index 00000000000..c0ac5670337 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/SpringSecurityUtils.groovy @@ -0,0 +1,826 @@ +/* + * 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.CompileDynamic +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.Filter +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpSession + +import org.apache.commons.text.StringEscapeUtils + +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.ApplicationContext +import org.springframework.security.access.hierarchicalroles.RoleHierarchy +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserCache +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.web.WebAttributes +import org.springframework.security.web.authentication.switchuser.SwitchUserFilter +import org.springframework.security.web.authentication.switchuser.SwitchUserGrantedAuthority +import org.springframework.security.web.savedrequest.SavedRequest +import org.springframework.util.StringUtils +import org.springframework.web.multipart.MultipartHttpServletRequest + +import grails.core.GrailsApplication +import grails.plugin.springsecurity.web.GrailsSecurityFilterChain +import grails.plugin.springsecurity.web.SecurityRequestHolder +import grails.util.Environment + +import static org.springframework.security.web.context.HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY + +/** + * Helper methods. + * + * @author Burt Beckwith + */ +@CompileStatic +@Slf4j +final class SpringSecurityUtils { + + private static final String MULTIPART_HTTP_SERVLET_REQUEST_KEY = MultipartHttpServletRequest.name + + private static ConfigObject _securityConfig + private static GrailsApplication application + + /** Ordered filter names. Plugins add or remove them, and can be overridden by config. */ + static Map orderedFilters = [:] + + /** Set by SpringSecurityCoreGrailsPlugin contains the actual filter beans in order. */ + static SortedMap configuredOrderedFilters = new TreeMap() + + /** Authentication provider names. Plugins add or remove them, and can be overridden by config. */ + static List providerNames = [] + + /** Logout handler names. Plugins add or remove them, and can be overridden by config. */ + static List logoutHandlerNames = [] + + /** AfterInvocationProvider names. Plugins add or remove them, and can be overridden by config. */ + static List afterInvocationManagerProviderNames = [] + + /** Voter names. Plugins add or remove them and can be overridden by config. */ + static List voterNames = [] + + // HttpSessionRequestCache.SAVED_REQUEST is package-scope + public static final String SAVED_REQUEST = 'SPRING_SECURITY_SAVED_REQUEST' // TODO use requestCache + + // UsernamePasswordAuthenticationFilter.SPRING_SECURITY_LAST_USERNAME_KEY is deprecated + public static final String SPRING_SECURITY_LAST_USERNAME_KEY = 'SPRING_SECURITY_LAST_USERNAME' + + // AbstractAuthenticationTargetUrlRequestHandler.DEFAULT_TARGET_PARAMETER was removed + public static final String DEFAULT_TARGET_PARAMETER = 'spring-security-redirect' + + /** Default value for the name of the Ajax header. */ + public static final String AJAX_HEADER = 'X-Requested-With' + + /** + * Used to ensure that all authenticated users have at least one granted authority to work + * around Spring Security code that assumes at least one. By granting this non-authority, + * the user can't do anything but gets past the somewhat arbitrary restrictions. + */ + public static final String NO_ROLE = 'ROLE_NO_ROLES' + + public static final String XML_HTTP_REQUEST = 'XMLHttpRequest' + public static final String FILTERS_NONE = 'none' + + private SpringSecurityUtils() { + // static only + } + + /** + * Set at startup by plugin. + * @param app the application + */ + static void setApplication(GrailsApplication app) { + application = app + initializeContext() + } + + /** + * Extract the role names from authorities. + * @param authorities the authorities (a collection or array of {@link GrantedAuthority}). + * @return the names + */ + static Set authoritiesToRoles(authorities) { + Set roles = new HashSet() + for (authority in ReflectionUtils.asList(authorities)) { + String authorityName = ((GrantedAuthority) authority).authority + assert authorityName != null, + "Cannot process GrantedAuthority objects which return null from getAuthority() - attempting to process $authority" + roles << authorityName + } + + roles + } + + /** + * Get the current user's authorities. + * @return a list of authorities (empty if not authenticated). + */ + @CompileDynamic + static Collection getPrincipalAuthorities() { + Authentication authentication = getAuthentication() + if (!authentication) { + return Collections.emptyList() + } + + Collection authorities = authentication.authorities + if (authorities == null) { + return Collections.emptyList() + } + + // remove the fake role if it's there + Collection copy = ([] + authorities) as Collection + for (Iterator iter = copy.iterator(); iter.hasNext();) { + if (NO_ROLE == iter.next().authority) { + iter.remove() + } + } + + copy + } + + /** + * Split the role names and create {@link GrantedAuthority}s for each. + * @param roleNames comma-delimited role names + * @return authorities (possibly empty) + */ + static List parseAuthoritiesString(String roleNames) { + List requiredAuthorities = [] + for (String auth in StringUtils.commaDelimitedListToStringArray(roleNames)) { + auth = auth.trim() + if (auth) { + requiredAuthorities << new SimpleGrantedAuthority(auth) + } + } + + requiredAuthorities + } + + /** + * Find authorities in granted that are also in required. + * @param granted the granted authorities (a collection or array of {@link GrantedAuthority}). + * @param required the required authorities (a collection or array of {@link GrantedAuthority}). + * @return the authority names + */ + static Set retainAll(granted, required) { + Set grantedRoles = authoritiesToRoles(granted) + grantedRoles.retainAll authoritiesToRoles(required) + grantedRoles + } + + /** + * Check if the current user has all of the specified roles. + * @param roles a comma-delimited list of role names + * @return true if the user is authenticated and has all the roles + */ + static boolean ifAllGranted(String roles) { + ifAllGranted parseAuthoritiesString(roles) + } + + static boolean ifAllGranted(Collection roles) { + authoritiesToRoles(findInferredAuthorities(principalAuthorities)).containsAll authoritiesToRoles(roles) + } + + /** + * Check if the current user has none of the specified roles. + * @param roles a comma-delimited list of role names + * @return true if the user is authenticated and has none the roles + */ + static boolean ifNotGranted(String roles) { + ifNotGranted parseAuthoritiesString(roles) + } + + static boolean ifNotGranted(Collection roles) { + !retainAll(findInferredAuthorities(principalAuthorities), roles) + } + + /** + * Check if the current user has any of the specified roles. + * @param roles a comma-delimited list of role names + * @return true if the user is authenticated and has any the roles + */ + static boolean ifAnyGranted(String roles) { + ifAnyGranted parseAuthoritiesString(roles) + } + + static boolean ifAnyGranted(Collection roles) { + retainAll findInferredAuthorities(principalAuthorities), roles + } + + /** + * Parse and load the security configuration. + * @return the configuration + */ + static synchronized ConfigObject getSecurityConfig() { + if (_securityConfig == null) { + log.trace 'Building security config since there is no cached config' + reloadSecurityConfig() + } + + _securityConfig + } + + /** + * For testing only. + * @param config the config + */ + static void setSecurityConfig(ConfigObject config) { + _securityConfig = config + } + + /** Reset the config for testing or after a dev mode Config.groovy change. */ + static synchronized void resetSecurityConfig() { + _securityConfig = null + log.trace 'reset security config' + } + + /** + * Allow a secondary plugin to add config attributes. + * @param className the name of the config class. + */ + static synchronized void loadSecondaryConfig(String className) { + mergeConfig securityConfig, className + log.trace 'loaded secondary config {}', className + } + + /** Force a reload of the security configuration. */ + static void reloadSecurityConfig() { + mergeConfig ReflectionUtils.securityConfig, 'DefaultSecurityConfig' + log.trace 'reloaded security config' + } + + /** + * Check if the request was triggered by an Ajax call. + * @param request the request + * @return true if Ajax + */ + static boolean isAjax(HttpServletRequest request) { + + String ajaxHeaderName = (String) ReflectionUtils.getConfigProperty('ajaxHeader') + + // check the current request's headers + if (XML_HTTP_REQUEST == request.getHeader(ajaxHeaderName)) { + return true + } + + def ajaxCheckClosure = ReflectionUtils.getConfigProperty('ajaxCheckClosure') + if (ajaxCheckClosure instanceof Closure) { + def result = ajaxCheckClosure(request) + if (result instanceof Boolean && result) { + return true + } + } + + // look for an ajax=true parameter + if ('true' == request.getParameter('ajax')) { + return true + } + + // process multipart requests + MultipartHttpServletRequest multipart = (MultipartHttpServletRequest) request.getAttribute(MULTIPART_HTTP_SERVLET_REQUEST_KEY) + if ('true' == multipart?.getParameter('ajax')) { + return true + } + + // check the SavedRequest's headers + HttpSession httpSession = request.getSession(false) + if (httpSession) { + SavedRequest savedRequest = (SavedRequest) httpSession.getAttribute(SAVED_REQUEST) + if (savedRequest) { + return savedRequest.getHeaderValues(ajaxHeaderName).contains(MULTIPART_HTTP_SERVLET_REQUEST_KEY) + } + } + + false + } + + /** + * Register a provider bean name. + * + * Note - only for use by plugins during bean building. + * + * @param beanName the Spring bean name of the provider + */ + static void registerProvider(String beanName) { + providerNames.add 0, beanName + log.trace 'Registered bean "{}" as a provider', beanName + } + + /** + * Register a logout handler bean name. + * + * Note - only for use by plugins during bean building. + * + * @param beanName the Spring bean name of the handler + */ + static void registerLogoutHandler(String beanName) { + logoutHandlerNames.add 0, beanName + log.trace 'Registered bean "{}" as a logout handler', beanName + } + + /** + * Register an AfterInvocationProvider bean name. + * + * Note - only for use by plugins during bean building. + * + * @param beanName the Spring bean name of the provider + */ + static void registerAfterInvocationProvider(String beanName) { + afterInvocationManagerProviderNames.add 0, beanName + log.trace 'Registered bean "{}" as an AfterInvocationProvider', beanName + } + + /** + * Register a voter bean name. + * + * Note - only for use by plugins during bean building. + * + * @param beanName the Spring bean name of the voter + */ + static void registerVoter(String beanName) { + voterNames.add 0, beanName + log.trace 'Registered bean "{}" as a voter', beanName + } + + /** + * Register a filter bean name in a specified position in the chain. + * + * Note - only for use by plugins during bean building - to register at runtime + * (preferably in BootStrap) use clientRegisterFilter. + * + * @param beanName the Spring bean name of the filter + * @param position the position + */ + static void registerFilter(String beanName, SecurityFilterPosition position) { + registerFilter beanName, position.order + } + + /** + * Register a filter bean name in a specified position in the chain. + * + * Note - only for use by plugins during bean building - to register at runtime + * (preferably in BootStrap) use clientRegisterFilter. + * + * @param beanName the Spring bean name of the filter + * @param order the position (see {@link SecurityFilterPosition}) + */ + static void registerFilter(String beanName, int order) { + String oldName = orderedFilters[order] + assert oldName == null, "Cannot register filter '$beanName' at position $order; '$oldName' is already registered in that position" + orderedFilters[order] = beanName + + log.trace 'Registered bean "{}" as a filter at order {}', beanName, order + } + + /** + * Register a filter in a specified position in the chain. + * + * Note - this is for use in application code after the plugin has initialized, + * e.g. in BootStrap where you want to register a custom filter in the correct + * order without dealing with the existing configured filters. + * + * @param beanName the Spring bean name of the filter + * @param position the position + */ + static void clientRegisterFilter(String beanName, SecurityFilterPosition position) { + clientRegisterFilter beanName, position.order + } + + /** + * Register a filter in a specified position in the chain. + * + * Note - this is for use in application code after the plugin has initialized, + * e.g. in BootStrap where you want to register a custom filter in the correct + * order without dealing with the existing configured filters. + * + * @param beanName the Spring bean name of the filter + * @param order the position (see {@link SecurityFilterPosition}) + */ + @SuppressWarnings('deprecation') + static void clientRegisterFilter(String beanName, int order) { + Filter oldFilter = configuredOrderedFilters.get(order) + assert !oldFilter, + "Cannot register filter '$beanName' at position $order; '$oldFilter' is already registered in that position" + + def filter = getBean(beanName) + if (filter instanceof FilterRegistrationBean) { + filter = ((FilterRegistrationBean) filter).filter + } + configuredOrderedFilters[order] = (Filter) filter + + List filterChains = getBean('securityFilterChains', List) + mergeFilterChains configuredOrderedFilters, (Filter) filter, beanName, order, filterChains + + log.trace 'Client registered bean "{}" as a filter at order {}', beanName, order + log.trace 'Updated filter chain: {}', filterChains + } + + private static void mergeFilterChains(Map orderedFilters, Filter filter, String beanName, + int order, List filterChains) { + + Map filterToPosition = new HashMap() + orderedFilters.each { Integer position, Filter f -> filterToPosition[f] = position } + + List> chainMap = (List) (ReflectionUtils.getConfigProperty('filterChain.chainMap') ?: []) + for (GrailsSecurityFilterChain filterChain in filterChains) { + + if (noFilterIsApplied(chainMap, filterChain.matcherPattern) || filterIsExcluded(chainMap, filterChain.matcherPattern, beanName)) { + continue + } + + List filters = filterChain.filters.collect() // copy + int index = 0 + while (index < filters.size() && filterToPosition[filters[index]] < order) { + index++ + } + filters.add index, filter + + filterChain.filters.clear() + filterChain.filters.addAll filters + } + } + + static boolean noFilterIsApplied(List> chainMap, String pattern) { + Map entry = chainMap.find { Map entry -> entry.pattern == pattern } + if (!entry) { + return false + } + String filters = entry.filters ?: '' + String[] filtersArray = filters.split(',') + (filtersArray.size() == 1 && filtersArray[0] == FILTERS_NONE) + } + + private static boolean filterIsExcluded(List> chainMap, String pattern, String filterName) { + for (Map entry in chainMap) { + if (entry.pattern != pattern) { + continue + } + + String filters = entry.filters + for (item in filters.split(',')) { + item = item.toString().trim() + if (item.startsWith('-') && item.substring(1) == filterName) { + return true + } + } + return false + } + + return false + } + + /** + * Check if the current user is switched to another user. + * @return true if logged in and switched + */ + static boolean isSwitched() { + findInferredAuthorities(principalAuthorities).any { authority -> + (authority instanceof SwitchUserGrantedAuthority) || + SwitchUserFilter.ROLE_PREVIOUS_ADMINISTRATOR == ((GrantedAuthority) authority).authority + } + } + + /** + * Get the username of the original user before switching to another. + * @return the original login name + */ + static String getSwitchedUserOriginalUsername() { + if (isSwitched()) { + ((SwitchUserGrantedAuthority) authentication.authorities.find({ it instanceof SwitchUserGrantedAuthority }))?.source?.name + } + } + + /** + * Lookup the security type as a String to avoid dev mode reload issues. + * @return the name of the SecurityConfigType + */ + static String getSecurityConfigType() { + securityConfig.securityConfigType + } + + /** + * Rebuild an Authentication for the given username and register it in the security context. + * Typically used after updating a user's authorities or other auth-cached info. + * + * Also removes the user from the user cache to force a refresh at next login. + * + * @param username the user's login name + * @param password optional + */ + static void reauthenticate(String username, String password) { + UserDetails userDetails = getBean('userDetailsService', UserDetailsService).loadUserByUsername(username) + + SecurityContextHolder.context.authentication = new UsernamePasswordAuthenticationToken( + userDetails, password == null ? userDetails.password : password, userDetails.authorities) + + getBean('userCache', UserCache).removeUserFromCache username + } + + /** + * Execute a closure with the current authentication. Assumes that there's an authentication in the + * http session and that the closure is running in a separate thread from the web request, so the + * context and authentication aren't available to the standard ThreadLocal. + * + * @param closure the code to run + * @return the closure's return value + */ + static doWithAuth(Closure closure) { + boolean set = false + if (!authentication && SecurityRequestHolder.request) { + HttpSession httpSession = SecurityRequestHolder.request.getSession(false) + if (httpSession) { + def securityContext = httpSession.getAttribute(SPRING_SECURITY_CONTEXT_KEY) + if (securityContext instanceof SecurityContext) { + SecurityContextHolder.context = (SecurityContext) securityContext + set = true + } + } + } + + try { + closure() + } + finally { + if (set) { + SecurityContextHolder.clearContext() + } + } + } + + /** + * Authenticate as the specified user and execute the closure with that authentication. Restores + * the authentication to the one that was active if it exists, or clears the context otherwise. + * + * This is similar to run-as and switch-user but is only local to a Closure. + * + * @param username the username to authenticate as + * @param closure the code to run + * @return the closure's return value + */ + static doWithAuth(String username, Closure closure) { + Authentication previousAuth = authentication + reauthenticate username, null + + try { + closure() + } + finally { + if (!previousAuth) { + SecurityContextHolder.clearContext() + } else { + SecurityContextHolder.context.authentication = previousAuth + } + } + } + + static SecurityContext getSecurityContext(HttpSession session) { + def securityContext = session.getAttribute(SPRING_SECURITY_CONTEXT_KEY) + if (securityContext instanceof SecurityContext) { + (SecurityContext) securityContext + } + } + + /** + * Get the last auth exception. + * @param session the session + * @return the exception + */ + static Throwable getLastException(HttpSession session) { + (Throwable) session.getAttribute(WebAttributes.AUTHENTICATION_EXCEPTION) + } + + /** + * Get the last attempted username. + * @param session the session + * @return the username + */ + static String getLastUsername(HttpSession session) { + String username = (String) session.getAttribute(SPRING_SECURITY_LAST_USERNAME_KEY) + if (username) { + username = StringEscapeUtils.unescapeHtml4(username) + } + username + } + + /** + * Get the saved request from the session. + * @param session the session + * @return the saved request + */ + static SavedRequest getSavedRequest(HttpSession session) { + (SavedRequest) session.getAttribute(SAVED_REQUEST) + } + + /** + * Merge in a secondary config (provided by a plugin as defaults) into the main config. + * @param currentConfig the current configuration + * @param className the name of the config class to load + */ + private static void mergeConfig(ConfigObject currentConfig, String className) { + ConfigObject secondary = new ConfigSlurper(Environment.current.name).parse( + new GroovyClassLoader(this.classLoader).loadClass(className)) + _securityConfig = ReflectionUtils.securityConfig = mergeConfig(currentConfig, secondary.security as ConfigObject) + } + + /** + * Merge two configs together. The order is important if secondary is not null then + * start with that and merge the main config on top of that. This lets the secondary + * config act as default values but let user-supplied values in the main config override them. + * + * @param currentConfig the main config, starting from Config.groovy + * @param secondary new default values + * @return the merged configs + */ + private static ConfigObject mergeConfig(ConfigObject currentConfig, ConfigObject secondary) { + (secondary ?: new ConfigObject()).merge(currentConfig ?: new ConfigObject()) as ConfigObject + } + + private static Collection findInferredAuthorities(Collection granted) { + getBean('roleHierarchy', RoleHierarchy).getReachableGrantedAuthorities(granted) ?: (Collections.emptyList() as Collection) + } + + @SuppressWarnings('unchecked') + private static T getBean(String name, Class c = null) { + (T) application.mainContext.getBean(name, c) + } + + /** + * Called each time doWithApplicationContext() is invoked, so it's important to reset + * to default values when running integration and functional tests together. + */ + private static void initializeContext() { + voterNames.clear() + voterNames << 'authenticatedVoter' << 'roleVoter' << 'webExpressionVoter' << 'closureVoter' + + logoutHandlerNames.clear() + logoutHandlerNames << 'rememberMeServices' << 'securityContextLogoutHandler' + + providerNames.clear() + providerNames << 'daoAuthenticationProvider' << 'anonymousAuthenticationProvider' << 'rememberMeAuthenticationProvider' + + orderedFilters.clear() + + configuredOrderedFilters.clear() + + afterInvocationManagerProviderNames.clear() + } + + private static Authentication getAuthentication() { + SecurityContextHolder.context?.authentication + } + + static SortedMap findFilterChainNames(filterChainFilterNames, boolean useSecureChannel, + boolean useIpRestrictions, boolean useX509, boolean useDigestAuth, + boolean useBasicAuth, boolean useSwitchUserFilter) { + + SortedMap orderedNames = new TreeMap() + + // if the user listed the names, use those + if (filterChainFilterNames) { + // cheat and put them in the map in order - the key values don't matter in this case since + // the user has chosen the order and the map will be used to insert single filters, which + // wouldn't happen if they've defined the order already + filterChainFilterNames.eachWithIndex { String name, int index -> orderedNames[index] = name } + } else { + + orderedNames[SecurityFilterPosition.FIRST.order + 10] = 'securityRequestHolderFilter' + + if (useSecureChannel) { + orderedNames[SecurityFilterPosition.CHANNEL_FILTER.order] = 'channelProcessingFilter' + } + + // CONCURRENT_SESSION_FILTER + + orderedNames[SecurityFilterPosition.SECURITY_CONTEXT_FILTER.order] = 'securityContextPersistenceFilter' + + orderedNames[SecurityFilterPosition.LOGOUT_FILTER.order] = 'logoutFilter' + + if (useIpRestrictions) { + orderedNames[SecurityFilterPosition.LOGOUT_FILTER.order + 1] = 'ipAddressFilter' + } + + if (useX509) { + orderedNames[SecurityFilterPosition.X509_FILTER.order] = 'x509ProcessingFilter' + } + + // PRE_AUTH_FILTER + + // CAS_FILTER + + orderedNames[SecurityFilterPosition.FORM_LOGIN_FILTER.order] = 'authenticationProcessingFilter' + + // OPENID_FILTER + + // facebook + + if (useDigestAuth) { + orderedNames[SecurityFilterPosition.DIGEST_AUTH_FILTER.order] = 'digestAuthenticationFilter' + orderedNames[SecurityFilterPosition.EXCEPTION_TRANSLATION_FILTER.order + 1] = 'digestExceptionTranslationFilter' + } + + if (useBasicAuth) { + orderedNames[SecurityFilterPosition.BASIC_AUTH_FILTER.order] = 'basicAuthenticationFilter' + orderedNames[SecurityFilterPosition.EXCEPTION_TRANSLATION_FILTER.order + 1] = 'basicExceptionTranslationFilter' + } + + // REQUEST_CACHE_FILTER + + orderedNames[SecurityFilterPosition.SERVLET_API_SUPPORT_FILTER.order] = 'securityContextHolderAwareRequestFilter' + + orderedNames[SecurityFilterPosition.REMEMBER_ME_FILTER.order] = 'rememberMeAuthenticationFilter' + + orderedNames[SecurityFilterPosition.ANONYMOUS_FILTER.order] = 'anonymousAuthenticationFilter' + + // SESSION_MANAGEMENT_FILTER + + orderedNames[SecurityFilterPosition.EXCEPTION_TRANSLATION_FILTER.order] = 'exceptionTranslationFilter' + + orderedNames[SecurityFilterPosition.FILTER_SECURITY_INTERCEPTOR.order] = 'filterInvocationInterceptor' + + if (useSwitchUserFilter) { + orderedNames[SecurityFilterPosition.SWITCH_USER_FILTER.order] = 'switchUserProcessingFilter' + } + + orderedNames[SecurityFilterPosition.EXCEPTION_TRANSLATION_FILTER.order - 10] = 'formContentFilter' + + // add in filters contributed by secondary plugins + orderedNames << SpringSecurityUtils.orderedFilters + } + + orderedNames + } + + static void buildFilterChains(SortedMap filterNames, List> chainMap, + List filterChains, ApplicationContext applicationContext) { + + filterChains.clear() + + def allConfiguredFilters = [:] + filterNames.each { Integer order, String name -> + def filter = applicationContext.getBean(name) + if (filter instanceof FilterRegistrationBean) { + filter = ((FilterRegistrationBean) filter).filter + } + allConfiguredFilters[name] = (Filter) filter + SpringSecurityUtils.configuredOrderedFilters[order] = (Filter) filter + } + log.trace 'Ordered filters: {}', SpringSecurityUtils.configuredOrderedFilters + + if (chainMap) { + for (Map entry in chainMap) { + String value = (entry.filters ?: '').toString().trim() + List filters + if (value.toLowerCase() == FILTERS_NONE) { + filters = Collections.emptyList() + } else if (value.contains('JOINED_FILTERS')) { + // special case to use either the filters defined by conf.filterChain.filterNames or + // the filters defined by config settings; can also remove one or more with a prefix of - + def copy = [:] << allConfiguredFilters + for (item in value.split(',')) { + item = item.toString().trim() + if (item == 'JOINED_FILTERS') continue + if (item.startsWith('-')) { + item = item.substring(1) + copy.remove item + } else { + throw new IllegalArgumentException("Cannot add a filter to JOINED_FILTERS, can only remove: $item") + } + } + filters = copy.values() as List + } else { + // explicit filter names + filters = value.toString().split(',').collect { String name -> applicationContext.getBean(name, Filter) } + } + filterChains << new GrailsSecurityFilterChain(entry.pattern as String, filters) + } + } else { + filterChains << new GrailsSecurityFilterChain('/**', allConfiguredFilters.values() as List) + } + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/NullAfterInvocationProvider.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/NullAfterInvocationProvider.groovy new file mode 100644 index 00000000000..ad5272af68c --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/NullAfterInvocationProvider.groovy @@ -0,0 +1,46 @@ +/* + * 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.access + +import groovy.transform.CompileStatic + +import org.springframework.security.access.AfterInvocationProvider +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.Authentication + +/** + * No-op implementation. + * + * @author Burt Beckwith + */ +@CompileStatic +class NullAfterInvocationProvider implements AfterInvocationProvider { + + def decide(Authentication a, o, Collection attrs, returnedObject) { + returnedObject + } + + boolean supports(ConfigAttribute attribute) { + false + } + + boolean supports(Class clazz) { + false + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/intercept/NullAfterInvocationManager.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/intercept/NullAfterInvocationManager.groovy new file mode 100644 index 00000000000..90ec999cbc8 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/intercept/NullAfterInvocationManager.groovy @@ -0,0 +1,46 @@ +/* + * 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.access.intercept + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.intercept.AfterInvocationManager +import org.springframework.security.core.Authentication + +/** + * No-op implementation. + * + * @author Burt Beckwith + */ +@CompileStatic +class NullAfterInvocationManager implements AfterInvocationManager { + + def decide(Authentication a, o, Collection attrs, returnedObject) { + returnedObject + } + + boolean supports(ConfigAttribute attribute) { + false + } + + boolean supports(Class clazz) { + true + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy new file mode 100644 index 00000000000..df151257d3d --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManager.groovy @@ -0,0 +1,120 @@ +/* + * 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.access.vote + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.vote.AbstractAccessDecisionManager +import org.springframework.security.access.vote.AuthenticatedVoter +import org.springframework.security.authentication.InsufficientAuthenticationException +import org.springframework.security.core.Authentication + +/** + * Uses the affirmative-based logic for roles, i.e. any in the list will grant access, but allows + * an authenticated voter to 'veto' access. This allows specification of roles and + * IS_AUTHENTICATED_FULLY on one line in SecurityConfig.groovy. + * + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class AuthenticatedVetoableDecisionManager extends AbstractAccessDecisionManager { + + AuthenticatedVetoableDecisionManager(List decisionVoters) { + super(decisionVoters) + } + + void decide(Authentication authentication, object, Collection configAttributes) + throws AccessDeniedException, InsufficientAuthenticationException { + + boolean authenticatedVotersGranted = checkAuthenticatedVoters(authentication, object, configAttributes) + boolean otherVotersGranted = checkOtherVoters(authentication, object, configAttributes) + + log.trace "decide(): authenticatedVotersGranted=$authenticatedVotersGranted otherVotersGranted=$otherVotersGranted" + + if (!authenticatedVotersGranted && !otherVotersGranted) { + checkAllowIfAllAbstainDecisions() + } + } + + /** + * Allow any {@link AuthenticatedVoter} to veto. If any voter denies, + * throw an exception; if any grant, return true; + * otherwise return false if all abstain. + */ + @SuppressWarnings(['rawtypes', 'unchecked']) + protected boolean checkAuthenticatedVoters(Authentication authentication, object, Collection configAttributes) { + + boolean grant = false + for (AccessDecisionVoter voter in decisionVoters) { + if (voter instanceof AuthenticatedVoter) { + boolean voterSupportsSecurityContext = object == null || voter.supports(object.class) + if (voterSupportsSecurityContext) { + int result = voter.vote(authentication, object, configAttributes) + switch (result) { + case AccessDecisionVoter.ACCESS_GRANTED: grant = true; break + case AccessDecisionVoter.ACCESS_DENIED: deny(); break + default: break // abstain + } + } + } + } + + grant + } + + /** + * Check the other (non-{@link AuthenticatedVoter}) voters. If any voter grants, + * return true. If any voter denies, throw exception. Otherwise return false + * to indicate that all abstained. + */ + @SuppressWarnings(['rawtypes', 'unchecked']) + protected boolean checkOtherVoters(Authentication authentication, object, Collection configAttributes) { + int denyCount = 0 + for (AccessDecisionVoter voter in getDecisionVoters()) { + if (voter instanceof AuthenticatedVoter) { + continue + } + boolean voterSupportsSecurityContext = object == null || voter.supports(object.class) + if (voterSupportsSecurityContext) { + int result = voter.vote(authentication, object, configAttributes) + switch (result) { + case AccessDecisionVoter.ACCESS_GRANTED: return true + case AccessDecisionVoter.ACCESS_DENIED: denyCount++; break + default: break // abstain + } + } + } + + if (denyCount) { + deny() + } + + // all abstain + false + } + + protected void deny() { + throw new AccessDeniedException(messages.getMessage('AbstractAccessDecisionManager.accessDenied', 'Access is denied')) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/ClosureConfigAttribute.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/ClosureConfigAttribute.groovy new file mode 100644 index 00000000000..d06730d55cf --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/ClosureConfigAttribute.groovy @@ -0,0 +1,44 @@ +/* + * 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.access.vote + +import groovy.transform.CompileStatic + +import org.springframework.security.access.ConfigAttribute + +/** + * @author Burt Beckwith + */ +@CompileStatic +class ClosureConfigAttribute implements ConfigAttribute { + + private static final long serialVersionUID = 1 + + final Closure closure + + /** + * Constructor. + * @param closure the closure + */ + ClosureConfigAttribute(Closure closure) { + this.closure = closure + } + + String getAttribute() {} +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/ClosureVoter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/ClosureVoter.groovy new file mode 100644 index 00000000000..78f60f93545 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/access/vote/ClosureVoter.groovy @@ -0,0 +1,76 @@ +/* + * 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.access.vote + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.core.Authentication +import org.springframework.security.web.FilterInvocation + +import grails.plugin.springsecurity.annotation.SecuredClosureDelegate + +/** + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class ClosureVoter implements AccessDecisionVoter, ApplicationContextAware { + + ApplicationContext applicationContext + + int vote(Authentication authentication, FilterInvocation fi, Collection attributes) { + assert authentication, 'authentication cannot be null' + assert fi, 'object cannot be null' + assert attributes != null, 'attributes cannot be null' + + log.trace 'vote() Authentication {}, FilterInvocation {} ConfigAttributes {}', authentication, fi, attributes + + ClosureConfigAttribute attribute = (ClosureConfigAttribute) attributes.find { it instanceof ClosureConfigAttribute } + + if (!attribute) { + log.trace 'No ClosureConfigAttribute found' + return ACCESS_ABSTAIN + } + + Closure closure = (Closure) attribute.closure.clone() + closure.delegate = new SecuredClosureDelegate(authentication, fi, applicationContext) + def result = closure.call() + if (result instanceof Boolean) { + log.trace 'Closure result: {}', result + return result ? ACCESS_GRANTED : ACCESS_DENIED + } + + log.warn 'vote() returning ACCESS_DENIED because the return value from the closure call was {}, not boolean', result?.getClass()?.name + + ACCESS_DENIED + } + + boolean supports(ConfigAttribute attribute) { + attribute instanceof ClosureConfigAttribute + } + + boolean supports(Class clazz) { + clazz.isAssignableFrom FilterInvocation + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/Authorities.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/Authorities.groovy new file mode 100644 index 00000000000..23ad1925cba --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/Authorities.groovy @@ -0,0 +1,48 @@ +/* + * 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.annotation + +import java.lang.annotation.Documented +import java.lang.annotation.ElementType +import java.lang.annotation.Inherited +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +import org.codehaus.groovy.transform.GroovyASTTransformationClass + +/** + * Specify the property file key with this annotation, and the AST transform + * class will replace with an @Secured annotation with the associated role names. + * + * @author Burt Beckwith + */ +@Target([ElementType.FIELD, ElementType.METHOD, ElementType.TYPE]) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@GroovyASTTransformationClass('grails.plugin.springsecurity.annotation.AuthoritiesTransformation') +@interface Authorities { + + /** + * The property file key; the property value will be a comma-delimited list of role names. + * @return the key + */ + String value() +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/AuthoritiesTransformation.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/AuthoritiesTransformation.groovy new file mode 100644 index 00000000000..67f94424de8 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/AuthoritiesTransformation.groovy @@ -0,0 +1,123 @@ +/* Copyright 2013-2016 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package grails.plugin.springsecurity.annotation + +import groovy.transform.CompileStatic +import org.codehaus.groovy.ast.ASTNode +import org.codehaus.groovy.ast.AnnotatedNode +import org.codehaus.groovy.ast.AnnotationNode +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.expr.ConstantExpression +import org.codehaus.groovy.ast.expr.Expression +import org.codehaus.groovy.ast.expr.ListExpression +import org.codehaus.groovy.control.CompilePhase +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.control.messages.SyntaxErrorMessage +import org.codehaus.groovy.syntax.SyntaxException +import org.codehaus.groovy.transform.ASTTransformation +import org.codehaus.groovy.transform.GroovyASTTransformation + +import org.springframework.util.StringUtils + +/** + * See http://burtbeckwith.com/blog/?p=1398 for the motivation for this. + * + * @author Burt Beckwith + */ +@CompileStatic +@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) +class AuthoritiesTransformation implements ASTTransformation { + + protected static ClassNode SECURED = new ClassNode(Secured) + + void visit(ASTNode[] astNodes, SourceUnit sourceUnit) { + ASTNode firstNode = astNodes[0] + ASTNode secondNode = astNodes[1] + try { + if (!(firstNode instanceof AnnotationNode) || !(secondNode instanceof AnnotatedNode)) { + throw new IllegalArgumentException("Internal error: wrong types: ${firstNode.getClass().name} / ${secondNode.getClass().name}") + } + + AnnotationNode rolesAnnotationNode = (AnnotationNode) firstNode + AnnotatedNode annotatedNode = (AnnotatedNode) secondNode + + AnnotationNode secured = createAnnotation(rolesAnnotationNode, sourceUnit) + if (secured) { + annotatedNode.addAnnotation secured + } + } + catch (e) { + reportError e.message, sourceUnit, firstNode + } + } + + protected AnnotationNode createAnnotation(AnnotationNode rolesNode, SourceUnit sourceUnit) throws IOException { + Expression value = rolesNode.members.value + if (!(value instanceof ConstantExpression)) { + reportError("annotation @Authorities value isn't a ConstantExpression: $value", sourceUnit, rolesNode) + return null + } + + String fieldName = value.text + String[] authorityNames = getAuthorityNames(fieldName, rolesNode, sourceUnit) + if (authorityNames == null) { + return null + } + + buildAnnotationNode authorityNames + } + + protected AnnotationNode buildAnnotationNode(String[] authorityNames) { + AnnotationNode securedAnnotationNode = new AnnotationNode(SECURED) + List nameExpressions = authorityNames.collect { String authorityName -> + new ConstantExpression(authorityName) + } as List + securedAnnotationNode.addMember 'value', new ListExpression(nameExpressions) + securedAnnotationNode + } + + protected String[] getAuthorityNames(String fieldName, AnnotationNode rolesNode, SourceUnit sourceUnit) throws IOException { + + Properties properties = new Properties() + File propertyFile = new File('roles.properties') + if (!propertyFile.exists()) { + reportError('Property file roles.properties not found', sourceUnit, rolesNode) + return null + } + + properties.load new FileReader(propertyFile) + + def value = properties.getProperty(fieldName) + if (value == null) { + reportError("No value for property '$fieldName'", sourceUnit, rolesNode) + return null + } + + List names = [] + for (String auth in StringUtils.commaDelimitedListToStringArray(value.toString())) { + auth = auth.trim() + if (auth) { + names << auth + } + } + + names as String[] + } + + protected void reportError(String message, SourceUnit sourceUnit, ASTNode node) { + SyntaxException se = new SyntaxException(message, node.lineNumber, node.columnNumber) + sourceUnit.errorCollector.addErrorAndContinue new SyntaxErrorMessage(se, sourceUnit) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/Secured.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/Secured.groovy new file mode 100644 index 00000000000..a469b574987 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/Secured.groovy @@ -0,0 +1,62 @@ +/* + * 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.annotation + +import java.lang.annotation.Documented +import java.lang.annotation.ElementType +import java.lang.annotation.Inherited +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +/** + * Annotation for Controllers at the class level or per-action, defining what roles + * are required for the entire controller or action. + * + * @author Burt Beckwith + */ +@Target([ElementType.METHOD, ElementType.TYPE]) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +@interface Secured { + + /** + * Default value for httpMethod(). + */ + String ANY_METHOD = 'ANY' + + /** + * Defines the security configuration attributes (e.g. ROLE_USER, ROLE_ADMIN, IS_AUTHENTICATED_REMEMBERED, etc.) + * @return the names of the roles, expressions, and tokens + */ + String[] value() default [] + + /** + * Optional attribute to specify the HTTP method required. + * @return the method + */ + String httpMethod() default 'ANY' + + /** + * Optional attribute to specify a closure that will be evaluated to decide if access should be allowed. + * @return the closure class + */ + Class closure() default Secured +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/SecuredClosureDelegate.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/SecuredClosureDelegate.groovy new file mode 100644 index 00000000000..533c95c7155 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/annotation/SecuredClosureDelegate.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.annotation + +import groovy.transform.CompileStatic + +import org.springframework.context.ApplicationContext +import org.springframework.security.access.PermissionEvaluator +import org.springframework.security.access.hierarchicalroles.RoleHierarchy +import org.springframework.security.authentication.AuthenticationTrustResolver +import org.springframework.security.core.Authentication +import org.springframework.security.web.FilterInvocation +import org.springframework.security.web.access.expression.WebSecurityExpressionRoot + +import grails.web.servlet.mvc.GrailsParameterMap +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.util.GrailsApplicationAttributes + +/** + * Set as the delegate of a closure in @Secured annotations; provides access to the request and application context, + * as well as all of the methods and properties available when using SpEL. + * + * @author Burt Beckwith + */ +@CompileStatic +class SecuredClosureDelegate extends WebSecurityExpressionRoot { + + ApplicationContext ctx + + SecuredClosureDelegate(Authentication a, FilterInvocation fi, ApplicationContext ctx) { + super(a, fi) + this.ctx = ctx + setTrustResolver ctx.getBean('authenticationTrustResolver', AuthenticationTrustResolver) + setRoleHierarchy ctx.getBean('roleHierarchy', RoleHierarchy) + setPermissionEvaluator ctx.getBean('permissionEvaluator', PermissionEvaluator) + } + + GrailsParameterMap getParams() { + GrailsWebRequest gwr = (GrailsWebRequest) request.getAttribute(GrailsApplicationAttributes.WEB_REQUEST) + gwr?.params + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/GrailsAnonymousAuthenticationProvider.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/GrailsAnonymousAuthenticationProvider.groovy new file mode 100644 index 00000000000..60e886a4cca --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/GrailsAnonymousAuthenticationProvider.groovy @@ -0,0 +1,40 @@ +/* + * 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.authentication + +import groovy.transform.CompileStatic + +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException + +/** + * @author Burt Beckwith + */ +@CompileStatic +class GrailsAnonymousAuthenticationProvider implements AuthenticationProvider { + + Authentication authenticate(Authentication authentication) throws AuthenticationException { + supports(authentication.getClass()) ? authentication : null + } + + boolean supports(Class authenticationClass) { + GrailsAnonymousAuthenticationToken.isAssignableFrom authenticationClass + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/GrailsAnonymousAuthenticationToken.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/GrailsAnonymousAuthenticationToken.groovy new file mode 100644 index 00000000000..cfec5ea4314 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/GrailsAnonymousAuthenticationToken.groovy @@ -0,0 +1,53 @@ +/* + * 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.authentication + +import groovy.transform.CompileStatic + +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.SpringSecurityCoreVersion +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails + +/** + * @author Burt Beckwith + */ +@CompileStatic +class GrailsAnonymousAuthenticationToken extends AnonymousAuthenticationToken { + + // TODO use this + private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID + + public static final String USERNAME = '__grails.anonymous.user__' + public static final String PASSWORD = '' + public static final String ROLE_NAME = 'ROLE_ANONYMOUS' + public static final GrantedAuthority ROLE = new SimpleGrantedAuthority(ROLE_NAME) + public static final List ROLES = Collections.singletonList(ROLE) + public static final UserDetails USER_DETAILS = new User(USERNAME, PASSWORD, false, false, false, false, ROLES) + + /** + * Constructor. + */ + GrailsAnonymousAuthenticationToken(String key, details) { + super(key, USER_DETAILS, ROLES) + setDetails details + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/NullAuthenticationEventPublisher.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/NullAuthenticationEventPublisher.groovy new file mode 100644 index 00000000000..a5f064eb084 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/authentication/NullAuthenticationEventPublisher.groovy @@ -0,0 +1,40 @@ +/* + * 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.authentication + +import groovy.transform.CompileStatic + +import org.springframework.security.authentication.AuthenticationEventPublisher +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException + +/** + * @author Burt Beckwith + */ +@CompileStatic +class NullAuthenticationEventPublisher implements AuthenticationEventPublisher { + + void publishAuthenticationFailure(AuthenticationException e, Authentication a) { + // do nothing + } + + void publishAuthenticationSuccess(Authentication a) { + // do nothing + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/cache/SpringUserCacheFactoryBean.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/cache/SpringUserCacheFactoryBean.groovy new file mode 100644 index 00000000000..5ea0b736177 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/cache/SpringUserCacheFactoryBean.groovy @@ -0,0 +1,63 @@ +/* + * 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.cache + +import javax.cache.configuration.Configuration +import javax.cache.configuration.MutableConfiguration + +import groovy.transform.CompileStatic + +import org.springframework.beans.factory.FactoryBean +import org.springframework.beans.factory.InitializingBean +import org.springframework.cache.jcache.JCacheCache +import org.springframework.cache.jcache.JCacheCacheManager +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.cache.SpringCacheBasedUserCache +import org.springframework.util.Assert + +@CompileStatic +class SpringUserCacheFactoryBean implements FactoryBean, InitializingBean { + + JCacheCacheManager cacheManager + String cacheName + Configuration cacheConfig + private SpringCacheBasedUserCache springUserCache + + @Override + SpringCacheBasedUserCache getObject() throws Exception { + springUserCache + } + + @Override + Class getObjectType() { + SpringCacheBasedUserCache + } + + @Override + void afterPropertiesSet() throws Exception { + Assert.notNull(cacheManager, 'cacheManager mandatory') + Assert.notNull(cacheName, 'cacheName mandatory') + if (!cacheConfig) { + cacheConfig = new MutableConfiguration() + .setTypes(String, User) + .setStoreByValue(false) + } + springUserCache = new SpringCacheBasedUserCache(new JCacheCache(cacheManager.cacheManager.createCache(cacheName, cacheConfig))) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ChainedUserDetailsService.groovy new file mode 100644 index 00000000000..8767b4ec9c3 --- /dev/null +++ b/grails-spring-security/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/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy new file mode 100644 index 00000000000..7ea5f7d6c8d --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlender.groovy @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.plugin.springsecurity.componentbased + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.context.ApplicationContext +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.ProviderManager +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain + +/** + * Blends user-defined Spring Security configuration components (the patterns + * recommended by + * + * Spring Security without the WebSecurityConfigurerAdapter) into the + * Grails Spring Security plugin's runtime structures. + * + *

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

+ * + *

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

+ * + *

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

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

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

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

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

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

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

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

Defaults follow Spring Boot's defaults:

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

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

+ * + * @param userName the resolved {@code spring.security.user.name} value + * @param userPassword the resolved {@code spring.security.user.password} + * value (may be {@code null}) + * @param userRoles the resolved {@code spring.security.user.roles} value + * (may be {@code null}) + * @return the bridged {@code InMemoryUserDetailsManager}, or {@code null} if + * the bridge is not applicable + */ + static InMemoryUserDetailsManager bridgeSpringSecurityUserProperties(String userName, + String userPassword, List userRoles) { + if (!userName) { + return null + } + String password = userPassword ?: 'user' + String[] roles = (userRoles ?: ['USER']) as String[] + UserDetails user = User.builder() + .username(userName) + .password('{noop}' + password) + .roles(roles) + .build() + log.info 'Bridging spring.security.user.* properties: created in-memory user "{}" with roles {}', + userName, roles.toList() + new InMemoryUserDetailsManager([user]) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/DefaultPostAuthenticationChecks.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/DefaultPostAuthenticationChecks.groovy new file mode 100644 index 00000000000..bd3089d7301 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/DefaultPostAuthenticationChecks.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.userdetails + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.context.MessageSource +import org.springframework.context.MessageSourceAware +import org.springframework.context.support.MessageSourceAccessor +import org.springframework.security.authentication.CredentialsExpiredException +import org.springframework.security.core.SpringSecurityMessageSource +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsChecker + +/** + * Copy of the private class in AbstractUserDetailsAuthenticationProvider + * to make subclassing or replacement easier. + * + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class DefaultPostAuthenticationChecks implements UserDetailsChecker, MessageSourceAware { + + protected MessageSourceAccessor messages = SpringSecurityMessageSource.accessor + + @Override + void setMessageSource(MessageSource messageSource) { + this.messages = new MessageSourceAccessor(messageSource) + } + + void check(UserDetails user) { + if (!user.credentialsNonExpired) { + log.debug 'User account credentials have expired' + + throw new CredentialsExpiredException(messages.getMessage( + 'AbstractUserDetailsAuthenticationProvider.credentialsExpired', + 'User credentials have expired')) + } + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/DefaultPreAuthenticationChecks.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/DefaultPreAuthenticationChecks.groovy new file mode 100644 index 00000000000..3fe51a1f10a --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/DefaultPreAuthenticationChecks.groovy @@ -0,0 +1,73 @@ +/* + * 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.userdetails + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.context.MessageSource +import org.springframework.context.MessageSourceAware +import org.springframework.context.support.MessageSourceAccessor +import org.springframework.security.authentication.AccountExpiredException +import org.springframework.security.authentication.DisabledException +import org.springframework.security.authentication.LockedException +import org.springframework.security.core.SpringSecurityMessageSource +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsChecker + +/** + * Copy of the private class in AbstractUserDetailsAuthenticationProvider + * to make subclassing or replacement easier. + * + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class DefaultPreAuthenticationChecks implements UserDetailsChecker, MessageSourceAware { + + protected MessageSourceAccessor messages = SpringSecurityMessageSource.accessor + + @Override + void setMessageSource(MessageSource messageSource) { + this.messages = new MessageSourceAccessor(messageSource) + } + + void check(UserDetails user) { + if (!user.accountNonLocked) { + log.debug 'User account is locked' + + throw new LockedException(messages.getMessage('AbstractUserDetailsAuthenticationProvider.locked', + 'User account is locked')) + } + + if (!user.enabled) { + log.debug('User account is disabled') + + throw new DisabledException(messages.getMessage('AbstractUserDetailsAuthenticationProvider.disabled', + 'User is disabled')) + } + + if (!user.accountNonExpired) { + log.debug('User account is expired') + + throw new AccountExpiredException(messages.getMessage('AbstractUserDetailsAuthenticationProvider.expired', + 'User account has expired')) + } + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GormUserDetailsService.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GormUserDetailsService.groovy new file mode 100644 index 00000000000..4034fba8108 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GormUserDetailsService.groovy @@ -0,0 +1,133 @@ +/* + * 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.userdetails + +import groovy.util.logging.Slf4j + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UsernameNotFoundException + +import grails.core.GrailsApplication +import grails.gorm.transactions.Transactional +import grails.plugin.springsecurity.SpringSecurityUtils + +/** + * Default implementation of GrailsUserDetailsService that uses + * domain classes to load users and roles. + * + * @author Burt Beckwith + */ +@Slf4j +class GormUserDetailsService implements GrailsUserDetailsService { + + /** + * Some Spring Security classes (e.g. RoleHierarchyVoter) expect at least one role, so + * we give a user with no granted roles this one which gets past that restriction but + * doesn't grant anything. + */ + static final GrantedAuthority NO_ROLE = new SimpleGrantedAuthority(SpringSecurityUtils.NO_ROLE) + + /** Dependency injection for the application. */ + GrailsApplication grailsApplication + + @Transactional(readOnly = true, noRollbackFor = [IllegalArgumentException, UsernameNotFoundException]) + UserDetails loadUserByUsername(String username, boolean loadRoles) throws UsernameNotFoundException { + + def conf = SpringSecurityUtils.securityConfig + String userClassName = conf.userLookup.userDomainClassName + def dc = grailsApplication.getArtefact 'Domain', userClassName + if (!dc) { + throw new IllegalArgumentException("The specified user domain class '$userClassName' is not a domain class") + } + + Class User = dc.clazz + + def user = User.createCriteria().get { + if (conf.userLookup.usernameIgnoreCase) { + eq((conf.userLookup.usernamePropertyName), username, [ignoreCase: true]) + } else { + eq((conf.userLookup.usernamePropertyName), username) + } + } + + if (!user) { + log.warn 'User not found: {}', username + throw new NoStackUsernameNotFoundException() + } + + Collection authorities = loadAuthorities(user, username, loadRoles) + createUserDetails user, authorities + } + + UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + loadUserByUsername username, true + } + + protected Collection loadAuthorities(user, String username, boolean loadRoles) { + if (!loadRoles) { + return [] + } + + def conf = SpringSecurityUtils.securityConfig + + String authoritiesPropertyName = conf.userLookup.authoritiesPropertyName + String authorityPropertyName = conf.authority.nameField + + boolean useGroups = conf.useRoleGroups + String authorityGroupPropertyName = conf.authority.groupAuthorityNameField + + Collection userAuthorities = user."$authoritiesPropertyName" + def authorities + + if (useGroups) { + if (authorityGroupPropertyName) { + authorities = userAuthorities.collect { it."$authorityGroupPropertyName" }.flatten().unique().collect { new SimpleGrantedAuthority(it."$authorityPropertyName") } + } else { + log.warn 'Attempted to use group authorities, but the authority name field for the group class has not been defined.' + } + } else { + authorities = userAuthorities.collect { new SimpleGrantedAuthority(it."$authorityPropertyName") } + } + authorities ?: [NO_ROLE] + } + + protected UserDetails createUserDetails(user, Collection authorities) { + + def conf = SpringSecurityUtils.securityConfig + + String usernamePropertyName = conf.userLookup.usernamePropertyName + String passwordPropertyName = conf.userLookup.passwordPropertyName + String enabledPropertyName = conf.userLookup.enabledPropertyName + String accountExpiredPropertyName = conf.userLookup.accountExpiredPropertyName + String accountLockedPropertyName = conf.userLookup.accountLockedPropertyName + String passwordExpiredPropertyName = conf.userLookup.passwordExpiredPropertyName + + String username = user."$usernamePropertyName" + String password = user."$passwordPropertyName" + boolean enabled = enabledPropertyName ? user."$enabledPropertyName" : true + boolean accountExpired = accountExpiredPropertyName ? user."$accountExpiredPropertyName" : false + boolean accountLocked = accountLockedPropertyName ? user."$accountLockedPropertyName" : false + boolean passwordExpired = passwordExpiredPropertyName ? user."$passwordExpiredPropertyName" : false + + new GrailsUser(username, password, enabled, !accountExpired, !passwordExpired, + !accountLocked, authorities, user.id) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GrailsUser.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GrailsUser.groovy new file mode 100644 index 00000000000..51da641c179 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GrailsUser.groovy @@ -0,0 +1,61 @@ +/* + * 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.userdetails + +import groovy.transform.CompileStatic + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.User + +/** + * Extends the default Spring Security user class to contain the ID for efficient lookup + * of the domain class from the Authentication. + * + * @author Burt Beckwith + */ +@CompileStatic +class GrailsUser extends User { + + private static final long serialVersionUID = 1 + + final id + + /** + * Constructor. + * + * @param username the username presented to the + * DaoAuthenticationProvider + * @param password the password that should be presented to the + * DaoAuthenticationProvider + * @param enabled set to true if the user is enabled + * @param accountNonExpired set to true if the account has not expired + * @param credentialsNonExpired set to true if the credentials have not expired + * @param accountNonLocked set to true if the account is not locked + * @param authorities the authorities that should be granted to the caller if they + * presented the correct username and password and the user is enabled. Not null. + * @param id the id of the domain class instance used to populate this + */ + GrailsUser(String username, String password, boolean enabled, boolean accountNonExpired, + boolean credentialsNonExpired, boolean accountNonLocked, + Collection authorities, id) { + super(username, password, enabled, accountNonExpired, credentialsNonExpired, + accountNonLocked, authorities) + this.id = id + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GrailsUserDetailsService.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GrailsUserDetailsService.groovy new file mode 100644 index 00000000000..e1794294025 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/GrailsUserDetailsService.groovy @@ -0,0 +1,46 @@ +/* + * 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.userdetails + +import org.springframework.dao.DataAccessException +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException + +/** + * Extension of the standard interface that allows specifying whether or not to load roles + * from the database, e.g. for LDAP where role information is inferred from LDAP group membership. + * + * @author Burt Beckwith + */ +interface GrailsUserDetailsService extends UserDetailsService { + + /** + * Locates the user based on the username. + * + * @param username the username identifying the user whose data is required. + * @param loadRoles whether to load roles at the same time as loading the user + * + * @return a fully populated user record (never null) + * + * @throws UsernameNotFoundException if the user could not be found + * @throws DataAccessException if user could not be found for a repository-specific reason + */ + UserDetails loadUserByUsername(String username, boolean loadRoles) throws UsernameNotFoundException, DataAccessException +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/NoStackUsernameNotFoundException.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/NoStackUsernameNotFoundException.groovy new file mode 100644 index 00000000000..d8c7f9f39ae --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/userdetails/NoStackUsernameNotFoundException.groovy @@ -0,0 +1,44 @@ +/* + * 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.userdetails + +import groovy.transform.CompileStatic + +import org.springframework.security.core.userdetails.UsernameNotFoundException + +/** + * Lightweight exception that avoids the cost of filling in the stack frames. + * + * @author Burt Beckwith + */ +@CompileStatic +class NoStackUsernameNotFoundException extends UsernameNotFoundException { + + private static final long serialVersionUID = 1 + + NoStackUsernameNotFoundException() { + super('User not found') + } + + @Override + synchronized Throwable fillInStackTrace() { + // do nothing + this + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/GrailsRedirectStrategy.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/GrailsRedirectStrategy.groovy new file mode 100644 index 00000000000..f172d9cdd78 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/GrailsRedirectStrategy.groovy @@ -0,0 +1,70 @@ +/* + * 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 + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.web.PortResolver +import org.springframework.security.web.RedirectStrategy +import org.springframework.security.web.util.UrlUtils + +/** + * Builds absolute urls when using header check channel security to prevent the + * container from generating urls with an incorrect scheme. + * + * @author Burt Beckwith + */ +@CompileStatic +@Slf4j +class GrailsRedirectStrategy implements RedirectStrategy { + + /** Dependency injection for the port resolver. */ + PortResolver portResolver + + /** Dependency injection for useHeaderCheckChannelSecurity. */ + boolean useHeaderCheckChannelSecurity + + void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException { + String redirectUrl = calculateRedirectUrl(request, url) + redirectUrl = response.encodeRedirectURL(redirectUrl) + + log.debug "Redirecting to '{}'", redirectUrl + + response.sendRedirect redirectUrl + } + + protected String calculateRedirectUrl(HttpServletRequest request, String url) { + if (UrlUtils.isAbsoluteUrl(url)) { + return url + } + + url = request.contextPath + url + + if (!useHeaderCheckChannelSecurity) { + return url + } + + UrlUtils.buildFullRequestUrl request.scheme, request.serverName, + portResolver.getServerPort(request), url, null + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/GrailsSecurityFilterChain.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/GrailsSecurityFilterChain.groovy new file mode 100644 index 00000000000..d38c9862f47 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/GrailsSecurityFilterChain.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.web + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.Filter +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher + +/** + * Based on org.springframework.security.web.DefaultSecurityFilterChain which is final. + * + * @author Burt Beckwith + */ +@CompileStatic +@Slf4j +class GrailsSecurityFilterChain implements SecurityFilterChain { + + final RequestMatcher requestMatcher + final List filters + final String matcherPattern + + GrailsSecurityFilterChain(String matcherPattern, List filters) { + this.filters = filters.collect() // copy + this.matcherPattern = matcherPattern + requestMatcher = new AntPathRequestMatcher(matcherPattern, null, false) + log.info 'Creating filter chain: {}, {}', requestMatcher, filters + } + + boolean matches(HttpServletRequest request) { + requestMatcher.matches request + } + + String toString() { + "[$requestMatcher, $filters]" + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/NullFilterChainValidator.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/NullFilterChainValidator.groovy new file mode 100644 index 00000000000..6871c6c424e --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/NullFilterChainValidator.groovy @@ -0,0 +1,37 @@ +/* + * 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 + +import groovy.transform.CompileStatic + +import org.springframework.security.web.FilterChainProxy +import org.springframework.security.web.FilterChainProxy.FilterChainValidator + +/** + * No-op validator. + * + * @author Burt Beckwith + */ +@CompileStatic +class NullFilterChainValidator implements FilterChainValidator { + + void validate(FilterChainProxy filterChainProxy) { + // do nothing + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/SecurityRequestHolder.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/SecurityRequestHolder.groovy new file mode 100644 index 00000000000..6702b96fe94 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/SecurityRequestHolder.groovy @@ -0,0 +1,74 @@ +/* + * 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 + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +/** + * Uses a {@link ThreadLocal} to store the current request and response. + * + * @author Burt Beckwith + */ +@CompileStatic +final class SecurityRequestHolder { + + private static final ThreadLocal REQUEST_HOLDER = new ThreadLocal() + private static final ThreadLocal RESPONSE_HOLDER = new ThreadLocal() + + private SecurityRequestHolder() { + // static only + } + + /** + * Clear the saved request. + */ + static void reset() { + REQUEST_HOLDER.remove() + RESPONSE_HOLDER.remove() + } + + /** + * Set the current request and response. + * @param request the request + * @param response the response + */ + static void set(HttpServletRequest request, HttpServletResponse response) { + REQUEST_HOLDER.set request + RESPONSE_HOLDER.set response + } + + /** + * Get the current request. + * @return the request + */ + static HttpServletRequest getRequest() { + REQUEST_HOLDER.get() + } + + /** + * Get the current response. + * @return the response + */ + static HttpServletResponse getResponse() { + RESPONSE_HOLDER.get() + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/SecurityRequestHolderFilter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/SecurityRequestHolderFilter.groovy new file mode 100644 index 00000000000..1ff442b2467 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/SecurityRequestHolderFilter.groovy @@ -0,0 +1,124 @@ +/* + * 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 + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.web.PortMapper +import org.springframework.security.web.PortResolver +import org.springframework.web.filter.GenericFilterBean + +/** + * Stores the request and response in the {@link SecurityRequestHolder}. Also wraps the request in a + * wrapper that is aware of the X-Forwarded-Proto header and returns the correct value from isSecure(), + * getScheme(), and getServerPort() if the header is present. + * + * @author Burt Beckwith + */ +@CompileStatic +@Slf4j +class SecurityRequestHolderFilter extends GenericFilterBean { + + // dependency injections + boolean useHeaderCheckChannelSecurity + String secureHeaderName + String secureHeaderValue + String insecureHeaderName + String insecureHeaderValue + PortMapper portMapper + PortResolver portResolver + + void doFilter(ServletRequest req, ServletResponse response, FilterChain chain) throws IOException, ServletException { + + HttpServletRequest request = wrapRequest(req as HttpServletRequest) + + SecurityRequestHolder.set request, response as HttpServletResponse + + try { + chain.doFilter request, response + } + finally { + SecurityRequestHolder.reset() + } + } + + /** + * If using header check channel security, look for the specified header (typically 'X-Forwarded-Proto') + * and if found, return a request wrapper that returns the correct values for isSecure(), getScheme(), + * and getServerPort(). Note that the values are switched intentionally since they're configured for + * channel security. + * + * @param request the original request + * @return the original request or a wrapper for it + */ + protected HttpServletRequest wrapRequest(HttpServletRequest request) { + if (!useHeaderCheckChannelSecurity) { + return request + } + + if (request.getHeader(secureHeaderName) == insecureHeaderValue && request.scheme == 'http') { + return new HttpServletRequestWrapper(request) { + + boolean isSecure() { true } + + String getScheme() { 'https' } + + int getServerPort() { + int serverPort = portResolver.getServerPort(request) + Integer httpsPort = portMapper.lookupHttpsPort(serverPort) + if (httpsPort == null) { + log.warn 'No port mapping found for HTTP port {}', serverPort + httpsPort = serverPort + } + httpsPort + } + } + } + + if (request.getHeader(insecureHeaderName) == secureHeaderValue && request.scheme == 'https') { + return new HttpServletRequestWrapper(request) { + + boolean isSecure() { false } + + String getScheme() { 'http' } + + int getServerPort() { + int serverPort = portResolver.getServerPort(request) + Integer httpPort = portMapper.lookupHttpPort(serverPort) + if (httpPort == null) { + log.warn 'No port mapping found for HTTPS port {}', serverPort + httpPort = serverPort + } + httpPort + } + } + } + + request + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/UpdateRequestContextHolderExceptionTranslationFilter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/UpdateRequestContextHolderExceptionTranslationFilter.groovy new file mode 100644 index 00000000000..1f69bc80ce7 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/UpdateRequestContextHolderExceptionTranslationFilter.groovy @@ -0,0 +1,99 @@ +/* + * 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 + +import groovy.transform.CompileStatic + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.access.ExceptionTranslationFilter +import org.springframework.security.web.savedrequest.RequestCache +import org.springframework.web.context.request.RequestContextHolder + +import grails.async.web.AsyncGrailsWebRequest +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.util.WebUtils + +/** + * Replaces the current GrailsWebRequest with one that delegates to the real current instance but uses the request and + * response from the filter chain instead of the cached instances from earlier in the chain to ensure that controllers + * and other classes that access the request from the thread-local RequestContextHolder use the correct instances. + * + * @author Burt Beckwith + */ +@CompileStatic +class UpdateRequestContextHolderExceptionTranslationFilter extends ExceptionTranslationFilter { + + UpdateRequestContextHolderExceptionTranslationFilter(AuthenticationEntryPoint authenticationEntryPoint) { + super(authenticationEntryPoint) + } + + UpdateRequestContextHolderExceptionTranslationFilter(AuthenticationEntryPoint authenticationEntryPoint, RequestCache requestCache) { + super(authenticationEntryPoint, requestCache) + } + + void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { + HttpServletRequest request = (HttpServletRequest) req + HttpServletResponse response = (HttpServletResponse) res + GrailsWebRequest current = (GrailsWebRequest) RequestContextHolder.requestAttributes + if (current && !(current instanceof DelegatingGrailsWebRequest) && !(current instanceof DelegatingAsyncGrailsWebRequest)) { + if (current instanceof AsyncGrailsWebRequest) { + WebUtils.storeGrailsWebRequest new DelegatingAsyncGrailsWebRequest(request, response, current) + } else { + WebUtils.storeGrailsWebRequest new DelegatingGrailsWebRequest(request, response, current) + } + } + + super.doFilter request, response, chain + } +} + +@CompileStatic +class DelegatingGrailsWebRequest extends GrailsWebRequest { + + // GROOVY-12134 - Groovy 5 workaround not ignoring final methods for the `@Delegate` + @Delegate(excludes = ['getRequest', 'getResponse', 'getCurrentRequest', 'getCurrentResponse', 'setMultipartRequest', + 'getParams', 'getParameterMap', 'getOriginalParams', 'resetParams', 'addParametersFrom']) + GrailsWebRequest current + + DelegatingGrailsWebRequest(HttpServletRequest request, HttpServletResponse response, GrailsWebRequest current) { + super(request, response, current.attributes) + this.current = current + } +} + +@CompileStatic +class DelegatingAsyncGrailsWebRequest extends AsyncGrailsWebRequest { + + // GROOVY-12134 - Groovy 5 workaround not ignoring final methods for the `@Delegate` + @Delegate(excludes = ['getRequest', 'getResponse', 'getCurrentRequest', 'getCurrentResponse', 'setMultipartRequest', + 'getParams', 'getParameterMap', 'getOriginalParams', 'resetParams', 'addParametersFrom']) + AsyncGrailsWebRequest current + + DelegatingAsyncGrailsWebRequest(HttpServletRequest request, HttpServletResponse response, AsyncGrailsWebRequest current) { + super(request, response, current.attributes) + this.current = current + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/AjaxAwareAccessDeniedHandler.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/AjaxAwareAccessDeniedHandler.groovy new file mode 100644 index 00000000000..87cff7252e0 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/AjaxAwareAccessDeniedHandler.groovy @@ -0,0 +1,156 @@ +/* + * 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 + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.beans.factory.InitializingBean +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.authentication.AuthenticationTrustResolver +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.PortResolver +import org.springframework.security.web.WebAttributes +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.security.web.savedrequest.RequestCache + +import grails.plugin.springsecurity.ReflectionUtils +import grails.plugin.springsecurity.SpringSecurityUtils + +/** + * @author Burt Beckwith + */ +@CompileStatic +@Slf4j +class AjaxAwareAccessDeniedHandler implements AccessDeniedHandler, InitializingBean { + + protected String ajaxErrorPage + protected String errorPage + + /** Dependency injection for the {@link AuthenticationTrustResolver}. */ + AuthenticationTrustResolver authenticationTrustResolver + + /** Dependency injection for the request cache. */ + RequestCache requestCache + + /** Dependency injection for the port resolver. */ + PortResolver portResolver + + /** Dependency injection for whether to forward to render the denied page or redirect. */ + boolean useForward = true + + void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException { + + if (e && loggedIn && authenticationTrustResolver.isRememberMe(authentication)) { + // user has a cookie but is getting bounced because of IS_AUTHENTICATED_FULLY, + // so Spring Security won't save the original request + requestCache.saveRequest request, response + } + + if (response.committed) { + log.trace 'response is committed' + return + } + + boolean ajaxError = ajaxErrorPage != null && SpringSecurityUtils.isAjax(request) + if (errorPage == null && !ajaxError) { + log.trace 'Sending 403 for non-Ajax request without errorPage specified' + response.sendError HttpServletResponse.SC_FORBIDDEN, e.message + return + } + + if (useForward && (errorPage != null || ajaxError)) { + log.trace 'Forwarding to error page' + // Put exception into request scope (perhaps of use to a view) + request.setAttribute(WebAttributes.ACCESS_DENIED_403, e) + response.status = HttpServletResponse.SC_FORBIDDEN + request.getRequestDispatcher(ajaxError ? ajaxErrorPage : errorPage).forward request, response + return + } + + String redirectUrl + String serverURL = ReflectionUtils.grailsServerURL + if (serverURL == null) { + boolean includePort = true + String scheme = request.scheme + String serverName = request.serverName + int serverPort = portResolver.getServerPort(request) + String contextPath = request.contextPath + boolean inHttp = 'http' == scheme.toLowerCase() + boolean inHttps = 'https' == scheme.toLowerCase() + + if (inHttp && (serverPort == 80)) { + includePort = false + } else if (inHttps && (serverPort == 443)) { + includePort = false + } + redirectUrl = scheme + '://' + serverName + ((includePort) ? (':' + serverPort) : '') + contextPath + } else { + redirectUrl = serverURL + } + + if (ajaxError) { + redirectUrl += ajaxErrorPage + } else if (errorPage != null) { + redirectUrl += errorPage + } + + String encodedRedirectUrl = response.encodeRedirectURL(redirectUrl) + log.trace 'Redirecting to {}', encodedRedirectUrl + response.sendRedirect encodedRedirectUrl + } + + protected Authentication getAuthentication() { + SecurityContextHolder.context?.authentication + } + + protected boolean isLoggedIn() { + Authentication authentication = getAuthentication() + authentication && !authenticationTrustResolver.isAnonymous(authentication) + } + + /** + * Dependency injection for the error page, e.g. '/login/denied'. + * @param page the page + */ + void setErrorPage(final String page) { + assert page == null || page.startsWith('/'), "ErrorPage must begin with '/'" + errorPage = page + } + + /** + * Dependency injection for the Ajax error page, e.g. '/login/ajaxDenied'. + * @param page the page + */ + void setAjaxErrorPage(String page) { + assert page == null || page.startsWith('/'), "Ajax ErrorPage must begin with '/'" + ajaxErrorPage = page + } + + void afterPropertiesSet() { + assert portResolver, 'portResolver is required' + assert authenticationTrustResolver, 'authenticationTrustResolver is required' + assert requestCache, 'requestCache is required' + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/DefaultThrowableAnalyzer.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/DefaultThrowableAnalyzer.groovy new file mode 100644 index 00000000000..24328f18822 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/DefaultThrowableAnalyzer.groovy @@ -0,0 +1,48 @@ +/* + * 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 + +import groovy.transform.CompileStatic + +import jakarta.servlet.ServletException + +import org.springframework.security.web.util.ThrowableAnalyzer +import org.springframework.security.web.util.ThrowableCauseExtractor + +/** + * Copy of org.springframework.security.web.access.ExceptionTranslationFilter.DefaultThrowableAnalyzer which is private. + * + * @author Burt Beckwith + */ +@CompileStatic +class DefaultThrowableAnalyzer extends ThrowableAnalyzer { + + @Override + protected void initExtractorMap() { + super.initExtractorMap() + + registerExtractor ServletException, new ThrowableCauseExtractor() { + + Throwable extractCause(Throwable throwable) { + ThrowableAnalyzer.verifyThrowableHierarchy throwable, ServletException + ((ServletException) throwable).rootCause + } + } + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy new file mode 100644 index 00000000000..cd5d1a9c368 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/GrailsWebInvocationPrivilegeEvaluator.groovy @@ -0,0 +1,173 @@ +/* + * 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 + +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Method +import java.lang.reflect.Proxy + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.access.AccessDeniedException +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 + +/** + * createFilterInvocation() is private in the base class so this is required to create + * a mock request that works with Grails - more methods get called than are expected in the mock request + * that the base class uses. + * + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class GrailsWebInvocationPrivilegeEvaluator extends DefaultWebInvocationPrivilegeEvaluator { + + protected static final FilterChain DUMMY_CHAIN = new FilterChain() { + + void doFilter(ServletRequest req, ServletResponse res) { + throw new UnsupportedOperationException('GrailsWebInvocationPrivilegeEvaluator does not support filter chains') + } + } + + protected static final HttpServletResponse DUMMY_RESPONSE = DummyResponseCreator.createInstance() + + protected AbstractSecurityInterceptor interceptor + + /** + * Constructor. + * @param securityInterceptor the security interceptor + */ + GrailsWebInvocationPrivilegeEvaluator(final AbstractSecurityInterceptor securityInterceptor) { + super(securityInterceptor) + interceptor = securityInterceptor + } + + @Override + boolean isAllowed(String contextPath, String uri, String method, Authentication authentication) { + assert uri, 'uri parameter is required' + + if (contextPath == null) { + contextPath = '/ctxpath' + } + + FilterInvocation fi = createFilterInvocation(contextPath, uri, method) + log.trace "isAllowed: contextPath '{}' uri '{}' method '{}' Authentication {} FilterInvocation {}", + contextPath, uri, method, authentication, fi + + def metadataSource = interceptor.obtainSecurityMetadataSource() as FilterInvocationSecurityMetadataSource + def attrs = metadataSource.getAttributes(fi) + if (attrs == null) { + log.trace 'No ConfigAttributes found' + return !interceptor.rejectPublicInvocations + } + + if (!authentication) { + log.trace 'Not authenticated' + return false + } + + try { + interceptor.accessDecisionManager.decide authentication, fi, attrs + log.trace '{} allowed for {}', fi, authentication + true + } + catch (AccessDeniedException unauthorized) { + if (log.debugEnabled) { + log.debug "$fi denied for $authentication", GrailsUtil.deepSanitize(unauthorized) + } + false + } + } + + protected FilterInvocation createFilterInvocation(String contextPath, String uri, String method) { + assert uri, 'URI required' + new FilterInvocation(DummyRequestCreator.createInstance(contextPath, method, contextPath + uri), DUMMY_RESPONSE, DUMMY_CHAIN) + } +} + +@CompileStatic +class DummyRequestCreator { + + static HttpServletRequest createInstance(String contextPath, String httpMethod, String requestURI) { + Map attributes = [:] + + (HttpServletRequest) Proxy.newProxyInstance(HttpServletRequest.classLoader, + [HttpServletRequest] as Class[], new InvocationHandler() { + + def invoke(proxy, Method method, Object[] args) { + + String methodName = method.name + + if ('getContextPath' == methodName) return contextPath + if ('getMethod' == methodName) return httpMethod + if ('getRequestURI' == methodName) return requestURI + if ('setAttribute' == methodName) { + attributes[(String) args[0]] = args[1] + return null + } + if ('getAttribute' == methodName) { + return attributes[args[0]] + } + + if ('getProtocol' == methodName || 'getScheme' == methodName) return 'http' + if ('getServerName' == methodName) return 'localhost' + if ('getServerPort' == methodName) return 8080 + + if (methodName.startsWith('is')) return false + + if ('getParameterMap' == methodName) return Collections.emptyMap() + + if ('getAttributeNames' == methodName || + 'getHeaderNames' == methodName || + 'getHeaders' == methodName || + 'getLocales' == methodName || + 'getParameterNames' == methodName) { + return Collections.enumeration(Collections.emptySet()) + } + } + }) + } +} + +@CompileStatic +class DummyResponseCreator { + + static HttpServletResponse createInstance() { + (HttpServletResponse) Proxy.newProxyInstance(HttpServletResponse.classLoader, + [HttpServletResponse] as Class[], new InvocationHandler() { + + def invoke(proxy, Method method, Object[] args) { + throw new UnsupportedOperationException() + } + }) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/expression/WebExpressionConfigAttribute.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/expression/WebExpressionConfigAttribute.groovy new file mode 100644 index 00000000000..f0e7d9d5584 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/expression/WebExpressionConfigAttribute.groovy @@ -0,0 +1,54 @@ +/* + * 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.expression + +import groovy.transform.CompileStatic + +import org.springframework.expression.Expression +import org.springframework.security.access.ConfigAttribute + +/** + * Simple expression configuration attribute for use in web request authorizations. + * Based on the class of the same name in Spring Security which is package-default. + * + * @author Luke Taylor + * @author Burt Beckwith + */ +@CompileStatic +class WebExpressionConfigAttribute implements ConfigAttribute { + + private static final long serialVersionUID = 1 + + final Expression authorizeExpression + + /** + * Constructor. + * @param authorizeExpression the expression + */ + WebExpressionConfigAttribute(Expression authorizeExpression) { + this.authorizeExpression = authorizeExpression + } + + String getAttribute() {} + + @Override + String toString() { + authorizeExpression.expressionString + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/expression/WebExpressionVoter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/expression/WebExpressionVoter.groovy new file mode 100644 index 00000000000..6b035f4710e --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/expression/WebExpressionVoter.groovy @@ -0,0 +1,75 @@ +/* + * 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.expression + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.expression.EvaluationContext +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.expression.ExpressionUtils +import org.springframework.security.access.expression.SecurityExpressionHandler +import org.springframework.security.core.Authentication +import org.springframework.security.web.FilterInvocation + +/** + * Based on the class of the same name in Spring Security which uses the + * package-default WebExpressionConfigAttribute. + * + * @author Luke Taylor + * @author Burt Beckwith + */ +@CompileStatic +@Slf4j +class WebExpressionVoter implements AccessDecisionVoter { + + /** Dependency injection for the expression handler. */ + SecurityExpressionHandler expressionHandler + + int vote(Authentication authentication, FilterInvocation fi, Collection attributes) { + assert authentication, 'authentication cannot be null' + assert fi, 'object cannot be null' + assert attributes, 'attributes cannot be null' + + log.trace 'vote() Authentication {}, FilterInvocation {} ConfigAttributes {}', authentication, fi, attributes + + WebExpressionConfigAttribute weca = findConfigAttribute(attributes) + if (!weca) { + log.trace 'No WebExpressionConfigAttribute found' + return ACCESS_ABSTAIN + } + + EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, fi) + + ExpressionUtils.evaluateAsBoolean(weca.authorizeExpression, ctx) ? ACCESS_GRANTED : ACCESS_DENIED + } + + protected WebExpressionConfigAttribute findConfigAttribute(Collection attributes) { + (WebExpressionConfigAttribute) attributes.find { ConfigAttribute attribute -> attribute instanceof WebExpressionConfigAttribute } + } + + boolean supports(ConfigAttribute attribute) { + attribute instanceof WebExpressionConfigAttribute + } + + boolean supports(Class clazz) { + clazz.isAssignableFrom FilterInvocation + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AbstractFilterInvocationDefinition.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AbstractFilterInvocationDefinition.groovy new file mode 100644 index 00000000000..bf8a6e40474 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AbstractFilterInvocationDefinition.groovy @@ -0,0 +1,299 @@ +/* + * 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 java.util.concurrent.CopyOnWriteArrayList + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.context.support.MessageSourceAccessor +import org.springframework.http.HttpMethod +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.SecurityConfig +import org.springframework.security.access.vote.AuthenticatedVoter +import org.springframework.security.access.vote.RoleVoter +import org.springframework.security.core.SpringSecurityMessageSource +import org.springframework.security.web.FilterInvocation +import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource +import org.springframework.util.AntPathMatcher +import org.springframework.util.StringUtils +import org.springframework.web.util.UrlPathHelper + +import grails.plugin.springsecurity.InterceptedUrl +import grails.util.GrailsUtil + +/** + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +abstract class AbstractFilterInvocationDefinition implements FilterInvocationSecurityMetadataSource { + + protected static final Collection DENY = Collections.singletonList((ConfigAttribute) new SecurityConfig('_DENY_')) + protected static final Collection ALLOW404 = Collections.singletonList((ConfigAttribute) new SecurityConfig('permitAll')) + protected static final String ERROR404 = '__ERROR404__' + + protected RoleVoter roleVoter + protected AuthenticatedVoter authenticatedVoter + protected final List compiled = new CopyOnWriteArrayList() + protected MessageSourceAccessor messages = SpringSecurityMessageSource.accessor + protected AntPathMatcher urlMatcher = new AntPathMatcher() + protected boolean initialized + protected UrlPathHelper urlPathHelper = UrlPathHelper.defaultInstance + + /** Dependency injection for whether to reject if there's no matching rule. */ + boolean rejectIfNoRule + + /** + * Allows subclasses to be externally reset. + */ + void reset() { + // override if necessary + } + + Collection getAttributes(object) throws IllegalArgumentException { + assert object, 'Object must be a FilterInvocation' + assert supports(object.getClass()), 'Object must be a FilterInvocation' + + FilterInvocation filterInvocation = (FilterInvocation) object + + String url = determineUrl(filterInvocation) + if (url == ERROR404) { + return ALLOW404 + } + + log.trace 'getAttributes(): url is {} for FilterInvocation {}', url, filterInvocation + + Collection configAttributes = findConfigAttributes(url, filterInvocation.request.method) + + if (rejectIfNoRule && !configAttributes) { + log.trace 'Returning DENY, rejectIfNoRule is true and no ConfigAttributes' + // return something that cannot be valid this will cause the voters to abstain or deny + return DENY + } + + log.trace 'ConfigAttributes are {}', configAttributes + configAttributes + } + + protected String determineUrl(FilterInvocation filterInvocation) { + final HttpServletRequest request = filterInvocation.request + lowercaseAndStripQuerystring stripContextPath(urlPathHelper.getRequestUri(request), request) + } + + protected boolean stopAtFirstMatch() { + false + } + + // for testing + InterceptedUrl getInterceptedUrl(String url, HttpMethod httpMethod) { + + initialize() + + for (InterceptedUrl iu in compiled) { + if (iu.httpMethod == httpMethod && iu.pattern == url) { + return iu + } + } + } + + protected Collection findConfigAttributes(String url, String requestMethod) { + + initialize() + + Collection configAttributes + String configAttributePattern + + boolean stopAtFirstMatch = stopAtFirstMatch() + for (InterceptedUrl iu in compiled) { + + if (requestMethod && iu.httpMethod && iu.httpMethod != HttpMethod.valueOf(requestMethod)) { + log.debug "Request '{} {}' doesn't match '{} {}'", requestMethod, url, iu.httpMethod, iu.pattern + continue + } + + if (urlMatcher.match(iu.pattern, url)) { + if (configAttributes == null || urlMatcher.match(configAttributePattern, iu.pattern)) { + configAttributes = iu.configAttributes + configAttributePattern = iu.pattern + log.trace "new candidate for '{}': '{}':{}", url, iu.pattern, configAttributes + if (stopAtFirstMatch) { + break + } + } + } + } + + if (log.traceEnabled) { + if (configAttributes == null) { + log.trace "no config for '{}'", url + } else { + log.trace "config for '{}' is '{}':{}", url, configAttributePattern, configAttributes + } + } + + configAttributes + } + + protected void initialize() { + // override if necessary + } + + boolean supports(Class clazz) { + FilterInvocation.isAssignableFrom clazz + } + + Collection getAllConfigAttributes() { + try { + initialize() + } + catch (e) { + log.error e.message, GrailsUtil.deepSanitize(e) + } + + Collection all = new LinkedHashSet() + for (InterceptedUrl iu in compiled) { + all.addAll iu.configAttributes + } + Collections.unmodifiableCollection all + } + + /** + * Resolve the URI from {@link jakarta.servlet.http.HttpServletRequest} + * @param request The {@link jakarta.servlet.http.HttpServletRequest} + * + * @return The resolved URI string + * @deprecated Use {@link org.springframework.web.util.UrlPathHelper#getRequestUri(jakarta.servlet.http.HttpServletRequest request)} and {@link #stripContextPath} instead + */ + @Deprecated + protected String calculateUri(HttpServletRequest request) { + stripContextPath(urlPathHelper.getRequestUri(request), request) + } + + protected String stripContextPath(String uri, HttpServletRequest request) { + String contextPath = request.contextPath + if (contextPath && uri.startsWith(contextPath)) { + uri = uri.substring(contextPath.length()) + } + uri + } + + protected String lowercaseAndStripQuerystring(String url) { + + String fixed = url.toLowerCase() + + int firstQuestionMarkIndex = fixed.indexOf('?') + if (firstQuestionMarkIndex != -1) { + fixed = fixed.substring(0, firstQuestionMarkIndex) + } + + int firstHashtagIndex = fixed.indexOf('#') + if (firstHashtagIndex != -1) { + fixed = fixed.substring(0, firstHashtagIndex) + } + + fixed + } + + /** + * For debugging. + * @return an unmodifiable map of {@link AnnotationFilterInvocationDefinition}ConfigAttributeDefinition + * keyed by compiled patterns + */ + List getConfigAttributeMap() { + Collections.unmodifiableList compiled + } + + // fixes extra spaces, trailing commas, etc. + protected List split(String value) { + if (!value.startsWith('ROLE_') && !value.startsWith('IS_')) { + // an expression + return Collections.singletonList(value) + } + + String[] parts = StringUtils.commaDelimitedListToStringArray(value) + List cleaned = [] + for (String part in parts) { + part = part.trim() + if (part) { + cleaned << part + } + } + cleaned + } + + protected void compileAndStoreMapping(InterceptedUrl iu) { + String pattern = iu.pattern + HttpMethod method = iu.httpMethod + + String key = pattern.toLowerCase() + + Collection configAttributes = iu.configAttributes + + InterceptedUrl replaced = storeMapping(key, method, Collections.unmodifiableCollection(configAttributes)) + if (replaced) { + log.warn "Replaced rule for '{}' and ConfigAttributes {} with ConfigAttributes {}", key, replaced.configAttributes, configAttributes + } else { + log.trace "Storing ConfigAttributes {} for '{}' and HttpMethod {}", key, configAttributes, method + } + } + + protected InterceptedUrl storeMapping(String pattern, HttpMethod method, Collection configAttributes) { + + InterceptedUrl existing + for (InterceptedUrl iu : compiled) { + if (iu.pattern == pattern && iu.httpMethod == method) { + existing = iu + break + } + } + + if (existing) { + log.trace 'Replacing existing mapping {}', existing + compiled.remove existing + } + + InterceptedUrl mapping = new InterceptedUrl(pattern, method, configAttributes) + compiled << mapping + log.trace 'Stored mapping {} for pattern "{}", HttpMethod {}, ConfigAttributes {}', mapping, pattern, method, configAttributes + + existing + } + + protected void resetConfigs() { + compiled.clear() + } + + /** + * For admin/debugging - find all config attributes that apply to the specified URL (doesn't consider request method restrictions). + * @param url the URL + * @return matching attributes + */ + Collection findMatchingAttributes(String url) { + for (InterceptedUrl iu in compiled) { + if (urlMatcher.match(iu.pattern, url)) { + return iu.configAttributes + } + } + Collections.emptyList() + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AnnotationFilterInvocationDefinition.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AnnotationFilterInvocationDefinition.groovy new file mode 100644 index 00000000000..274022bd16c --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/AnnotationFilterInvocationDefinition.groovy @@ -0,0 +1,532 @@ +/* + * 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 java.lang.annotation.Annotation +import java.lang.reflect.AccessibleObject +import java.lang.reflect.Constructor +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.RequestDispatcher +import jakarta.servlet.ServletContext +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.http.HttpMethod +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.annotation.Secured as SpringSecured +import org.springframework.security.web.FilterInvocation +import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource +import org.springframework.util.ReflectionUtils +import org.springframework.util.StringUtils +import org.springframework.web.context.ServletContextAware + +import grails.core.GrailsApplication +import grails.core.GrailsClass +import grails.core.GrailsControllerClass +import grails.core.GrailsDomainClass +import grails.plugin.springsecurity.InterceptedUrl +import grails.plugin.springsecurity.ReflectionUtils as PluginReflectionUtils +import grails.plugin.springsecurity.access.vote.ClosureConfigAttribute +import grails.plugin.springsecurity.annotation.Secured as PluginSecured +import grails.rest.Resource +import grails.web.Action +import grails.web.UrlConverter +import grails.web.mapping.UrlMappingInfo +import grails.web.mapping.UrlMappingsHolder +import grails.web.servlet.mvc.GrailsParameterMap +import org.grails.core.artefact.ControllerArtefactHandler +import org.grails.web.mapping.mvc.GrailsControllerUrlMappingInfo +import org.grails.web.mime.HttpServletResponseExtension +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.util.WebUtils + +/** + * A {@link FilterInvocationSecurityMetadataSource} that uses rules defined with + * Controller annotations combined with static rules defined in + * SecurityConfig.groovy, e.g. for js, images, css or for rules + * that cannot be expressed in a controller like '/**'. + * + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class AnnotationFilterInvocationDefinition extends AbstractFilterInvocationDefinition implements ServletContextAware { + + protected static String SLASH = '/' + + protected UrlMappingsHolder urlMappingsHolder + + ServletContext servletContext + + /** Dependency injection for the application. */ + GrailsApplication application + + /** Dependency injection for the httpServletResponseExtension bean. */ + HttpServletResponseExtension httpServletResponseExtension + + /** Dependency injection for the grailsUrlConverter bean. */ + UrlConverter grailsUrlConverter + + @Override + protected String determineUrl(FilterInvocation filterInvocation) { + HttpServletRequest request = filterInvocation.httpRequest + HttpServletResponse response = filterInvocation.httpResponse + + String requestUrl = urlPathHelper.getRequestUri(request) + requestUrl = stripContextPath(requestUrl, request) + + GrailsWebRequest existingRequest + try { + existingRequest = WebUtils.retrieveGrailsWebRequest() + } + catch (IllegalStateException e) { + if (request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE) == 404) { + ERROR404 + } else { + requestUrl + } + } + + log.trace 'Requested url: {}', requestUrl + + String url + try { + GrailsWebRequest grailsRequest = new GrailsWebRequest(request, response, servletContext) + WebUtils.storeGrailsWebRequest grailsRequest + + Map savedParams = copyParams(grailsRequest) + + UrlMappingInfo[] urlInfos = PluginReflectionUtils.matchAllUrlMappings( + urlMappingsHolder, requestUrl, grailsRequest, httpServletResponseExtension) + + for (UrlMappingInfo mapping : urlInfos) { + if (mapping.redirectInfo) { + log.trace 'Mapping {} is a redirect', mapping + break + } + + configureMapping mapping, grailsRequest, savedParams + + url = findGrailsUrl(mapping) + if (url) { + break + } + } + } + finally { + if (existingRequest) { + WebUtils.storeGrailsWebRequest(existingRequest) + } else { + WebUtils.clearGrailsWebRequest() + } + } + + if (!StringUtils.hasLength(url)) { + // probably css/js/image + url = requestUrl + } + + String finalUrl = lowercaseAndStripQuerystring(url) + log.trace 'Final url is {}', finalUrl + finalUrl + } + + protected String findGrailsUrl(UrlMappingInfo mapping) { + + String uri = mapping.URI + if (uri) { + return uri + } + + String viewName = mapping.viewName + if (viewName != null) { + if (!viewName.startsWith(SLASH)) { + viewName = SLASH + viewName + } + return viewName + } + + if (!(mapping instanceof GrailsControllerUrlMappingInfo)) { + return + } + + String namespace = mapping.namespace + String controllerName = mapping.controllerName + if (namespace) { + controllerName = resolveFullControllerName(controllerName, namespace) + } + + createControllerUri controllerName, mapping.actionName ?: '' + } + + protected String createControllerUri(String controllerName, String actionName) { + if (!actionName || 'null' == actionName) { + actionName = 'index' + } + (SLASH + controllerName + SLASH + actionName).trim() + } + + protected void configureMapping(UrlMappingInfo mapping, GrailsWebRequest grailsRequest, Map savedParams) { + + // reset params since mapping.configure() sets values + GrailsParameterMap params = grailsRequest.params + params.clear() + params.putAll(savedParams) + + mapping.configure grailsRequest + } + + @SuppressWarnings('unchecked') + protected Map copyParams(GrailsWebRequest grailsRequest) { + ([:] << grailsRequest.params) as Map + } + + /** + * Called by the plugin to set controller role info.
+ * + * Reinitialize by calling ctx.objectDefinitionSource.initialize( + * ctx.authenticateService.securityConfig.security.annotationStaticRules, + * ctx.grailsUrlMappingsHolder, + * grailsApplication.controllerClasses) + * + * @param staticRules data from the controllerAnnotations.staticRules config attribute + * @param mappingsHolder mapping holder + * @param controllerClasses all controllers + * @param domainClasses all domain classes + */ + void initialize(staticRules, UrlMappingsHolder mappingsHolder, GrailsClass[] controllerClasses, GrailsClass[] domainClasses) { + + assert staticRules != null, 'staticRules map is required' + assert mappingsHolder, 'urlMappingsHolder is required' + + resetConfigs() + + urlMappingsHolder = mappingsHolder + + Map> actionRoles = [:] + List classRoles = [] + Map> actionClosures = [:] + List classClosures = [] + + for (GrailsClass controllerClass in controllerClasses) { + findControllerAnnotations((GrailsControllerClass) controllerClass, actionRoles, classRoles, actionClosures, classClosures) + } + + for (GrailsClass domainClass in domainClasses) { + findDomainAnnotations((GrailsDomainClass) domainClass, actionRoles, classRoles, actionClosures, classClosures) + } + + compileStaticRules staticRules + compileActionClosures actionClosures + compileClassClosures classClosures + compileActionRoles actionRoles + compileClassRoles classRoles + + if (log.traceEnabled) { + for (InterceptedUrl url in configAttributeMap) { + log.trace 'URL: {} | Roles: {}', url.pattern, url.configAttributes + } + } + } + + protected void compileActionRoles(Map> map) { + map.each { String controllerName, List urls -> + for (InterceptedUrl iu in urls) { + Collection configAttributes = iu.configAttributes + String actionName = iu.pattern + HttpMethod method = iu.httpMethod + storeMapping controllerName, actionName, configAttributes, false, method + if (actionName.endsWith('Flow')) { + // WebFlow actions end in Flow but are accessed without the suffix, so guard both + storeMapping controllerName, actionName.substring(0, actionName.length() - 4), configAttributes, false, method + } + } + } + } + + protected void compileActionClosures(Map> map) { + map.each { String controllerName, List actionClosures -> + for (InterceptedUrl iu in actionClosures) { + String actionName = iu.pattern + Class closureClass = iu.closureClass + HttpMethod method = iu.httpMethod + storeMapping controllerName, actionName, closureClass, method + if (actionName.endsWith('Flow')) { + // WebFlow actions end in Flow but are accessed without the suffix, so guard both + storeMapping controllerName, actionName.substring(0, actionName.length() - 4), closureClass, method + } + } + } + } + + protected void compileClassRoles(List classRoles) { + for (InterceptedUrl iu in classRoles) { + storeMapping iu.pattern, null, iu.configAttributes, false, iu.httpMethod + } + } + + protected void compileClassClosures(List classClosures) { + for (InterceptedUrl iu in classClosures) { + storeMapping iu.pattern, null, iu.closureClass, iu.httpMethod + } + } + + protected Closure newInstance(Class closureClass) { + try { + Constructor constructor = closureClass.getConstructor(Object, Object) + ReflectionUtils.makeAccessible constructor + (Closure) constructor.newInstance(this, this) + } + catch (NoSuchMethodException | InstantiationException | IllegalAccessException e) { + ReflectionUtils.handleReflectionException e + } + catch (InvocationTargetException e) { + ReflectionUtils.handleInvocationTargetException e + } + } + + @SuppressWarnings('unchecked') + protected void compileStaticRules(staticRules) { + if (staticRules instanceof Map) { + throw new IllegalArgumentException('Static rules defined as a Map are not supported; must be specified as a ' + + "List of Maps as described in section 'Configuring Request Mappings to Secure URLs' of the reference documentation") + } + + if (!(staticRules instanceof List)) { + return + } + + for (InterceptedUrl iu in PluginReflectionUtils.splitMap((List>) staticRules)) { + storeMapping iu.pattern, null, iu.configAttributes, true, iu.httpMethod + } + } + + protected void storeMapping(String controllerNameOrPattern, String actionName, + Collection configAttributes, boolean isPattern, HttpMethod method) { + + for (String pattern in generatePatterns(controllerNameOrPattern, actionName, isPattern)) { + doStoreMapping pattern, method, configAttributes + } + } + + protected void storeMapping(String controllerName, String actionName, Class closureClass, HttpMethod method) { + if (closureClass == PluginSecured) { + return + } + + for (String pattern in generatePatterns(controllerName, actionName, false)) { + Collection configAttributes = [new ClosureConfigAttribute(newInstance(closureClass))] as Collection + + String key = pattern.toLowerCase() + InterceptedUrl replaced = storeMapping(key, method, configAttributes) + if (replaced) { + log.warn "replaced rule for '{}' with tokens {} with tokens {}", + key, replaced.configAttributes, configAttributes + } + } + } + + protected List generatePatterns(String controllerNameOrPattern, String actionName, boolean isPattern) { + + if (isPattern) { + return [controllerNameOrPattern] + } + + StringBuilder sb = new StringBuilder() + sb << '/' << controllerNameOrPattern + if (actionName) { + sb << '/' << actionName + } + List patterns = [sb.toString(), sb.toString() + '.*'] // TODO + + if (actionName != '') { + sb << '/**' + } + + patterns << sb.toString() + + log.trace 'Patterns generated for controller "{}" action "{}" -> {}', controllerNameOrPattern, actionName, patterns + + patterns + } + + protected void doStoreMapping(String fullPattern, HttpMethod method, Collection configAttributes) { + String key = fullPattern.toString().toLowerCase() + InterceptedUrl replaced = storeMapping(key, method, configAttributes) + if (replaced) { + log.warn "Replaced rule for '{}' and ConfigAttributes {} with ConfigAttributes {}", key, replaced.configAttributes, configAttributes + } else { + log.trace "Storing ConfigAttributes {} for '{}' and HttpMethod {}", key, configAttributes, method + } + } + + protected void findControllerAnnotations(GrailsControllerClass controllerClass, Map> actionRoles, + List classRoles, Map> actionClosures, + List classClosures) { + + Class clazz = controllerClass.clazz + String controllerUri = resolveFullControllerName(controllerClass) + + findAnnotations actionRoles, classRoles, actionClosures, classClosures, clazz, controllerUri + } + + protected void findDomainAnnotations(GrailsDomainClass domainClass, Map> actionRoles, + List classRoles, Map> actionClosures, + List classClosures) { + + Class clazz = domainClass.clazz + if (clazz.getAnnotation(Resource)) { + findAnnotations actionRoles, classRoles, actionClosures, classClosures, clazz, clazz.simpleName.toLowerCase(), false + } + } + + private void findAnnotations(Map> actionRoles, List classRoles, + Map> actionClosures, List classClosures, + Class clazz, String controllerUri, boolean forController = true) { + + Annotation annotation = clazz.getAnnotation(SpringSecured) + if (!annotation) { + annotation = clazz.getAnnotation(PluginSecured) + if (annotation) { + Class closureClass = findClosureClass((PluginSecured) annotation) + if (closureClass) { + log.trace 'found class-scope annotation with a closure in {}', clazz.name + classClosures << new InterceptedUrl(controllerUri, closureClass, getHttpMethod(annotation)) + } else { + Collection values = getValue(annotation) + log.trace 'found class-scope annotation in {} with value(s) {}', clazz.name, values + classRoles << new InterceptedUrl(controllerUri, values, getHttpMethod(annotation)) + } + } + } else { + Collection values = getValue(annotation) + log.trace 'found class-scope annotation in {} with value(s) {}', clazz.name, values + classRoles << new InterceptedUrl(controllerUri, values, null) + } + + if (!forController) { + return + } + + List actionData = findActionRoles(clazz) + if (actionData) { + actionRoles[controllerUri] = actionData + } + + List closureAnnotatedData = findActionClosures(clazz) + if (closureAnnotatedData) { + actionClosures[controllerUri] = closureAnnotatedData + } + } + + protected String resolveFullControllerName(GrailsControllerClass controllerClass) { + String namespace = controllerClass.namespace + if (namespace) { + namespace = grailsUrlConverter.toUrlElement(namespace) + } + resolveFullControllerName grailsUrlConverter.toUrlElement(controllerClass.name), namespace + } + + protected String resolveFullControllerName(String controllerNameInUrlFormat, String namespaceInUrlFormat) { + String fullControllerName = namespaceInUrlFormat ? namespaceInUrlFormat + ':' + controllerNameInUrlFormat : controllerNameInUrlFormat + + log.trace 'Resolved full controller name for controller "{}" and namespace "{}" as "{}"', + controllerNameInUrlFormat, namespaceInUrlFormat, fullControllerName + + fullControllerName + } + + protected List findActionRoles(Class clazz) { + + log.trace 'finding @Secured annotations for actions in {}', clazz.name + + GrailsControllerClass cc = (GrailsControllerClass) application.getArtefact(ControllerArtefactHandler.TYPE, clazz.name) + String defaultAction = cc.defaultAction + + List actionRoles = [] + for (Method method in findActions(clazz)) { + Annotation annotation = findSecuredAnnotation(method) + if (annotation) { + Collection values = getValue(annotation) + if (values) { + log.trace 'found annotated method {} in {} with value(s) {}', method.name, clazz.name, values + HttpMethod httpMethod = getHttpMethod(annotation) + actionRoles << new InterceptedUrl(grailsUrlConverter.toUrlElement(method.name), values, httpMethod) + + if (method.name == defaultAction) { + actionRoles << new InterceptedUrl('', values, httpMethod) + } + } + } + } + actionRoles + } + + protected List findActionClosures(Class clazz) { + List actionClosures = [] + for (Method method in findActions(clazz)) { + PluginSecured annotation = method.getAnnotation(PluginSecured) + if (annotation && annotation.closure() != PluginSecured) { + log.trace 'found annotation with a closure on method {} in {}', method.name, clazz.name + actionClosures << new InterceptedUrl(grailsUrlConverter.toUrlElement(method.name), + annotation.closure(), getHttpMethod(annotation)) + } + } + actionClosures + } + + protected Collection findActions(Class clazz) { + return clazz.methods.findAll { it.getAnnotation(Action) != null } + } + + protected Class findClosureClass(PluginSecured annotation) { + Class closureClass = annotation.closure() + closureClass == PluginSecured ? null : closureClass + } + + protected Annotation findSecuredAnnotation(AccessibleObject annotatedTarget) { + annotatedTarget.getAnnotation(PluginSecured) ?: annotatedTarget.getAnnotation(SpringSecured) + } + + protected Collection getValue(Annotation annotation) { + String[] strings + if (annotation instanceof PluginSecured) { + strings = ((PluginSecured) annotation).value() + } else { + strings = ((SpringSecured) annotation).value() + } + new LinkedHashSet(Arrays.asList(strings)) + } + + protected HttpMethod getHttpMethod(Annotation annotation) { + String method + if (annotation instanceof PluginSecured) { + method = ((PluginSecured) annotation).httpMethod() + if (PluginSecured.ANY_METHOD == method) { + method = null + } + } + method == null ? null : HttpMethod.valueOf(method) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/ChannelFilterInvocationSecurityMetadataSourceFactoryBean.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/ChannelFilterInvocationSecurityMetadataSourceFactoryBean.groovy new file mode 100644 index 00000000000..2124a453b80 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/ChannelFilterInvocationSecurityMetadataSourceFactoryBean.groovy @@ -0,0 +1,86 @@ +/* + * 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 groovy.transform.CompileStatic + +import org.springframework.beans.factory.FactoryBean +import org.springframework.beans.factory.InitializingBean +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.SecurityConfig +import org.springframework.security.web.access.intercept.DefaultFilterInvocationSecurityMetadataSource +import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher +import org.springframework.util.AntPathMatcher + +/** + * Factory bean that builds a {@link FilterInvocationSecurityMetadataSource} for channel security. + * + * @author Burt Beckwith + */ +@CompileStatic +class ChannelFilterInvocationSecurityMetadataSourceFactoryBean implements FactoryBean, InitializingBean { + + protected static final Collection SUPPORTED = [ + 'ANY_CHANNEL', 'REQUIRES_SECURE_CHANNEL', 'REQUIRES_INSECURE_CHANNEL'] + protected AntPathMatcher urlMatcher = new AntPathMatcher() + protected DefaultFilterInvocationSecurityMetadataSource source + + /** + * Dependency injection for the definition maps. Each map has a single entry, with URL patterns stored under the + * 'pattern' key and ANY_CHANNEL, REQUIRES_SECURE_CHANNEL, or REQUIRES_INSECURE_CHANNEL stored under the 'access' key. + */ + List> definition + + FilterInvocationSecurityMetadataSource getObject() { + source + } + + Class getObjectType() { + DefaultFilterInvocationSecurityMetadataSource + } + + boolean isSingleton() { + true + } + + void afterPropertiesSet() { + assert definition, 'definition map is required' + assert urlMatcher, 'urlMatcher is required' + + source = new DefaultFilterInvocationSecurityMetadataSource(buildMap()) + } + + protected LinkedHashMap> buildMap() { + LinkedHashMap> map = [:] + definition.each { Map entry -> + String access = entry.access + assert access != null, "The rule for URL '$access' cannot be null" + access = access.trim() + + assert SUPPORTED.contains(access), + "The rule for URL '$access' must be one of REQUIRES_SECURE_CHANNEL, REQUIRES_INSECURE_CHANNEL, or ANY_CHANNEL" + + map[new AntPathRequestMatcher(entry.pattern, null, false)] = SecurityConfig.createList(access) + } + + map + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/InterceptUrlMapFilterInvocationDefinition.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/InterceptUrlMapFilterInvocationDefinition.groovy new file mode 100644 index 00000000000..7805a231c34 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/InterceptUrlMapFilterInvocationDefinition.groovy @@ -0,0 +1,69 @@ +/* + * 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 groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import grails.plugin.springsecurity.InterceptedUrl +import grails.plugin.springsecurity.ReflectionUtils + +/** + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class InterceptUrlMapFilterInvocationDefinition extends AbstractFilterInvocationDefinition { + + @Override + protected void initialize() { + if (!initialized) { + reset() + } + } + + @Override + protected boolean stopAtFirstMatch() { + true + } + + @SuppressWarnings('unchecked') + @Override + void reset() { + def interceptUrlMap = ReflectionUtils.getConfigProperty('interceptUrlMap') + + if (interceptUrlMap instanceof Map) { + throw new IllegalArgumentException('interceptUrlMap defined as a Map is not supported; must be specified as a ' + + "List of Maps as described in section 'Configuring Request Mappings to Secure URLs' of the reference documentation") + } + + if (!(interceptUrlMap instanceof List)) { + log.warn "interceptUrlMap config property isn't a List of Maps" + return + } + + resetConfigs() + + ReflectionUtils.splitMap((List>) interceptUrlMap).each { InterceptedUrl iu -> compileAndStoreMapping iu } + + initialized = true + + log.trace 'configs: {}', configAttributeMap + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/RequestmapFilterInvocationDefinition.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/RequestmapFilterInvocationDefinition.groovy new file mode 100644 index 00000000000..ee31ff052f8 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/access/intercept/RequestmapFilterInvocationDefinition.groovy @@ -0,0 +1,75 @@ +/* + * 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 groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.http.HttpMethod + +import grails.plugin.springsecurity.InterceptedUrl +import grails.plugin.springsecurity.ReflectionUtils + +/** + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class RequestmapFilterInvocationDefinition extends AbstractFilterInvocationDefinition { + + @Override + protected void initialize() { + if (initialized) { + return + } + + try { + reset() + initialized = true + } + catch (RuntimeException e) { + log.warn("Exception initializing; this is ok if it's at startup and due " + + 'to GORM not being initialized yet since the first web request will ' + + 're-initialize. Error message is: {}', e.message) + } + } + + /** + * Call at startup or when Requestmap instances have been added, removed, or changed. + */ + @Override + synchronized void reset() { + resetConfigs() + + loadRequestmaps().each { InterceptedUrl iu -> compileAndStoreMapping(iu) } + + log.trace 'configs: {}', configAttributeMap + } + + protected List loadRequestmaps() { + boolean supportsHttpMethod = ReflectionUtils.requestmapClassSupportsHttpMethod() + + ReflectionUtils.loadAllRequestmaps().collect { requestmap -> + String urlPattern = ReflectionUtils.getRequestmapUrl(requestmap) + String configAttribute = ReflectionUtils.getRequestmapConfigAttribute(requestmap) + HttpMethod method = supportsHttpMethod ? ReflectionUtils.getRequestmapHttpMethod(requestmap) : null + new InterceptedUrl(urlPattern, split(configAttribute), method) + } + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPoint.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPoint.groovy new file mode 100644 index 00000000000..ec680db3ea4 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPoint.groovy @@ -0,0 +1,97 @@ +/* + * 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.authentication + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.RedirectStrategy +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint + +import grails.plugin.springsecurity.SpringSecurityUtils + +/** + * @author Burt Beckwith + */ +@CompileStatic +@Slf4j +class AjaxAwareAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint { + + protected String ajaxLoginFormUrl + + /** Dependency injection for the RedirectStrategy. */ + RedirectStrategy redirectStrategy + + /** + * @param loginFormUrl URL where the login page can be found. Should either be relative to the web-app context path + * (include a leading {@code /}) or an absolute URL. + */ + AjaxAwareAuthenticationEntryPoint(String loginFormUrl) { + super(loginFormUrl) + } + + void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { + + if ('true'.equalsIgnoreCase(request.getHeader('nopage'))) { + response.sendError HttpServletResponse.SC_UNAUTHORIZED + return + } + + String redirectUrl + + if (useForward) { + if (forceHttps && 'http' == request.scheme) { + // First redirect the current request to HTTPS. + // When that request is received, the forward to the login page will be used. + redirectUrl = buildHttpsRedirectUrlForRequest(request) + } + + if (redirectUrl == null) { + String loginForm = determineUrlToUseForThisRequest(request, response, e) + log.debug 'Server side forward to: {}', loginForm + request.getRequestDispatcher(loginForm).forward request, response + return + } + } else { + // redirect to login page. Use https if forceHttps true + redirectUrl = buildRedirectUrlToLoginPage(request, response, e) + } + + redirectStrategy.sendRedirect request, response, redirectUrl + } + + @Override + protected String determineUrlToUseForThisRequest(HttpServletRequest req, HttpServletResponse res, AuthenticationException e) { + ajaxLoginFormUrl && SpringSecurityUtils.isAjax(req) ? ajaxLoginFormUrl : loginFormUrl + } + + /** + * Dependency injection for the Ajax login form url, e.g. '/login/authAjax'. + * @param url the url + */ + void setAjaxLoginFormUrl(String url) { + assert url == null || url.startsWith('/'), "ajaxLoginFormUrl must begin with '/'" + ajaxLoginFormUrl = url + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationFailureHandler.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationFailureHandler.groovy new file mode 100644 index 00000000000..c5eb4b6071e --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationFailureHandler.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.web.authentication + +import groovy.transform.CompileStatic + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.beans.factory.InitializingBean +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.authentication.ExceptionMappingAuthenticationFailureHandler + +import grails.plugin.springsecurity.SpringSecurityUtils + +/** + * Ajax-aware failure handler that detects failed Ajax logins and redirects to the appropriate URL. + * + * @author Burt Beckwith + */ +@CompileStatic +class AjaxAwareAuthenticationFailureHandler extends ExceptionMappingAuthenticationFailureHandler implements InitializingBean { + + /** Dependency injection for the Ajax auth fail url. */ + String ajaxAuthenticationFailureUrl + + @Override + void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + if (SpringSecurityUtils.isAjax(request)) { + saveException request, exception + redirectStrategy.sendRedirect request, response, ajaxAuthenticationFailureUrl + } else { + super.onAuthenticationFailure request, response, exception + } + } + + /** + * Dependency injection for the exception -> url mappings; each map has an 'exception' key and a 'url' key, and + * all are merged into one map, where each key is an exception name and each value is the url. + * @param mappings list of single-entry maps + */ + void setExceptionMappings(List> mappings) { + super.setExceptionMappings((Map) mappings.inject([:], { LinkedHashMap map, Map mapping -> map[mapping.exception] = mapping.url; map })) + } + + void setExceptionMappingsList(List> mappings) { + super.setExceptionMappings((Map) mappings.inject([:], { LinkedHashMap map, Map mapping -> map[mapping.exception] = mapping.url; map })) + } + + void afterPropertiesSet() { + assert ajaxAuthenticationFailureUrl, 'ajaxAuthenticationFailureUrl is required' + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationSuccessHandler.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationSuccessHandler.groovy new file mode 100644 index 00000000000..13f8fe7ed3d --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationSuccessHandler.groovy @@ -0,0 +1,77 @@ +/* + * 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.authentication + +import groovy.transform.CompileStatic + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler +import org.springframework.security.web.savedrequest.RequestCache + +import grails.plugin.springsecurity.SpringSecurityUtils + +/** + * @author Burt Beckwith + */ +@CompileStatic +class AjaxAwareAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + + protected RequestCache requestCache + + /** Dependency injection for the Ajax success url, e.g. '/login/ajaxSuccess'. */ + String ajaxSuccessUrl + + @Override + void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws ServletException, IOException { + + boolean ajax = SpringSecurityUtils.isAjax(request) + + // GPSPRINGSECURITYCORE-240 + if (ajax) { + requestCache.removeRequest request, response + } + + try { + if (ajax) { + clearAuthenticationAttributes request + if (logger.debugEnabled) { + logger.debug 'Redirecting to Ajax Success Url: ' + ajaxSuccessUrl + } + redirectStrategy.sendRedirect request, response, ajaxSuccessUrl + } else { + super.onAuthenticationSuccess request, response, authentication + } + } + finally { + // always remove the saved request + requestCache.removeRequest request, response + } + } + + @Override + void setRequestCache(RequestCache cache) { + super.setRequestCache cache + requestCache = cache + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/FilterProcessUrlRequestMatcher.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/FilterProcessUrlRequestMatcher.groovy new file mode 100644 index 00000000000..d6ea8084a72 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/FilterProcessUrlRequestMatcher.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.web.authentication + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.security.web.util.UrlUtils +import org.springframework.security.web.util.matcher.RequestMatcher + +/** + * Based on the class of the same name which is a private static inner class in + * org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter. + * + * @author Ben Alex + * @author Luke Taylor + * @author Burt Beckwith + */ +@CompileStatic +class FilterProcessUrlRequestMatcher implements RequestMatcher { + + final String filterProcessesUrl + + FilterProcessUrlRequestMatcher(String filterProcessesUrl) { + assert filterProcessesUrl, 'filterProcessesUrl must be specified' + assert UrlUtils.isValidRedirectUrl(filterProcessesUrl), "$filterProcessesUrl isn't a valid redirect URL" + this.filterProcessesUrl = filterProcessesUrl + } + + boolean matches(HttpServletRequest request) { + String uri = request.requestURI + + int pathParamIndex = uri.indexOf(';') + if (pathParamIndex > 0) { + // strip everything after the first semi-colon + uri = uri.substring(0, pathParamIndex) + } + + request.contextPath ? uri.endsWith(request.contextPath + filterProcessesUrl) : uri.endsWith(filterProcessesUrl) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/GrailsUsernamePasswordAuthenticationFilter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/GrailsUsernamePasswordAuthenticationFilter.groovy new file mode 100644 index 00000000000..4646044d12c --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/GrailsUsernamePasswordAuthenticationFilter.groovy @@ -0,0 +1,67 @@ +/* + * 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.authentication + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import jakarta.servlet.http.HttpSession + +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +import grails.plugin.springsecurity.SpringSecurityUtils + +/** + * Extends the default {@link UsernamePasswordAuthenticationFilter} to store the + * last attempted login username in the session under the 'SPRING_SECURITY_LAST_USERNAME' + * key if storeLastUsername is true. + * + * @author Burt Beckwith + */ +@CompileStatic +class GrailsUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + /** Whether to store the last attempted username in the session. */ + Boolean storeLastUsername + + @Override + Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + if (storeLastUsername) { + // Place the last username attempted into HttpSession for views + HttpSession session = request.getSession(false) + if (!session && allowSessionCreation) { + session = request.session + } + + session?.setAttribute SpringSecurityUtils.SPRING_SECURITY_LAST_USERNAME_KEY, (obtainUsername(request) ?: '').trim() + } + + super.attemptAuthentication request, response + } + + @Override + void afterPropertiesSet() { + super.afterPropertiesSet() + assert storeLastUsername != null, 'storeLastUsername must be set' + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/NullLogoutHandlerRememberMeServices.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/NullLogoutHandlerRememberMeServices.groovy new file mode 100644 index 00000000000..5222bcccd30 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/NullLogoutHandlerRememberMeServices.groovy @@ -0,0 +1,39 @@ +/* + * 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.authentication + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.NullRememberMeServices +import org.springframework.security.web.authentication.logout.LogoutHandler + +/** + * @author Burt Beckwith + */ +@CompileStatic +class NullLogoutHandlerRememberMeServices extends NullRememberMeServices implements LogoutHandler { + + void logout(HttpServletRequest req, HttpServletResponse res, Authentication a) { + // no-op + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/logout/MutableLogoutFilter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/logout/MutableLogoutFilter.groovy new file mode 100644 index 00000000000..91054e9e91a --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/logout/MutableLogoutFilter.groovy @@ -0,0 +1,87 @@ +/* + * 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.authentication.logout + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.authentication.logout.LogoutFilter +import org.springframework.security.web.authentication.logout.LogoutHandler +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler + +/** + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class MutableLogoutFilter extends LogoutFilter { + + protected final LogoutSuccessHandler logoutSuccessHandler + + /** Dependency injection for the logout handlers. */ + List handlers + + /** + * Constructor. + * @param successHandler the logout success handler + */ + MutableLogoutFilter(LogoutSuccessHandler successHandler) { + super(successHandler, new DummyLogoutHandler()) + logoutSuccessHandler = successHandler + } + + @Override + void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req + HttpServletResponse response = (HttpServletResponse) res + + if (!requiresLogout(request, response)) { + chain.doFilter request, response + return + } + + Authentication auth = SecurityContextHolder.context.authentication + log.debug "Logging out user '{}' and transferring to logout destination", auth + + handlers.each { LogoutHandler handler -> handler.logout request, response, auth } + + logoutSuccessHandler.onLogoutSuccess request, response, auth + } + + /** + * Null logout handler that's used to provide a non-empty list of handlers to the base class. + * The real handlers will be after construction. + */ + protected static class DummyLogoutHandler implements LogoutHandler { + + void logout(HttpServletRequest req, HttpServletResponse res, Authentication a) { + // do nothing + } + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/ClosureX509PrincipalExtractor.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/ClosureX509PrincipalExtractor.groovy new file mode 100644 index 00000000000..530f756da29 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/ClosureX509PrincipalExtractor.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.authentication.preauth.x509 + +import java.security.cert.X509Certificate + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.context.MessageSource +import org.springframework.context.MessageSourceAware +import org.springframework.context.support.MessageSourceAccessor +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.core.SpringSecurityMessageSource +import org.springframework.security.web.authentication.preauth.x509.X509PrincipalExtractor + +/** + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class ClosureX509PrincipalExtractor implements X509PrincipalExtractor, MessageSourceAware { + + protected MessageSourceAccessor messages = SpringSecurityMessageSource.accessor + + /** Dependency injection for the closure to use to extract the username. */ + Closure closure + + def extractPrincipal(X509Certificate clientCert) { + String subjectDN = clientCert.subjectDN.name + + log.debug "Subject DN is '{}'", subjectDN + + def username = closure.call(subjectDN) + if (username == null) { + final String code = 'SubjectDnX509PrincipalExtractor.noMatching' + final String defaultMessage = 'No matching pattern was found in subject DN: {}' + Object[] args = [subjectDN] as Object[] + throw new BadCredentialsException(messages.getMessage(code, args, defaultMessage)) + } + + log.debug "Extracted Principal name is '{}'", username + + username + } + + /** + * Dependency injection for the message source. + * @param messageSource the message source + */ + @Override + void setMessageSource(MessageSource messageSource) { + messages = new MessageSourceAccessor(messageSource) + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/NullAuthenticationFailureHandler.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/NullAuthenticationFailureHandler.groovy new file mode 100644 index 00000000000..8a8cda19c65 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/NullAuthenticationFailureHandler.groovy @@ -0,0 +1,39 @@ +/* + * 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.authentication.preauth.x509 + +import groovy.transform.CompileStatic + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.authentication.AuthenticationFailureHandler + +/** + * @author Burt Beckwith + */ +@CompileStatic +class NullAuthenticationFailureHandler implements AuthenticationFailureHandler { + + void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse res, AuthenticationException e) throws IOException, ServletException { + // no-op + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/NullAuthenticationSuccessHandler.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/NullAuthenticationSuccessHandler.groovy new file mode 100644 index 00000000000..dfd1b5b7511 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/preauth/x509/NullAuthenticationSuccessHandler.groovy @@ -0,0 +1,39 @@ +/* + * 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.authentication.preauth.x509 + +import groovy.transform.CompileStatic + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.AuthenticationSuccessHandler + +/** + * @author Burt Beckwith + */ +@CompileStatic +class NullAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + void onAuthenticationSuccess(HttpServletRequest req, HttpServletResponse res, Authentication a) throws IOException, ServletException { + // no-op + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/rememberme/GormPersistentTokenRepository.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/rememberme/GormPersistentTokenRepository.groovy new file mode 100644 index 00000000000..716a3b2c16c --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/rememberme/GormPersistentTokenRepository.groovy @@ -0,0 +1,101 @@ +/* + * 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.authentication.rememberme + +import groovy.util.logging.Slf4j + +import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository + +import grails.core.GrailsApplication +import grails.core.support.GrailsApplicationAware +import grails.gorm.DetachedCriteria +import grails.plugin.springsecurity.SpringSecurityUtils + +/** + * GORM-based PersistentTokenRepository implementation, based on {@link org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl}. + * + * @author Burt Beckwith + */ +@Slf4j +class GormPersistentTokenRepository implements PersistentTokenRepository, GrailsApplicationAware { + + /** Dependency injection for grailsApplication. */ + GrailsApplication grailsApplication + + void createNewToken(PersistentRememberMeToken token) { + def clazz = lookupDomainClass() + if (!clazz) return + + // join an existing transaction if one is active + clazz.withTransaction { status -> + clazz.newInstance(username: token.username, series: token.series, + token: token.tokenValue, lastUsed: token.date).save() + } + } + + PersistentRememberMeToken getTokenForSeries(String seriesId) { + def persistentToken + def clazz = lookupDomainClass() + if (clazz) { + // join an existing transaction if one is active + clazz.withTransaction { status -> + persistentToken = clazz.get(seriesId) + } + } + if (!persistentToken) { + return null + } + + new PersistentRememberMeToken(persistentToken.username, persistentToken.series, persistentToken.token, persistentToken.lastUsed) + } + + void removeUserTokens(String username) { + def clazz = lookupDomainClass() + if (!clazz) return + + clazz.withTransaction { status -> + // can't use 'where' query with variable class, probably confuses the AST, so create the same DetachedCriteria + new DetachedCriteria(clazz).eq('username', username).deleteAll() + } + } + + void updateToken(String series, String tokenValue, Date lastUsed) { + def clazz = lookupDomainClass() + if (!clazz) return + + // join an existing transaction if one is active + clazz.withTransaction { status -> + def persistentLogin = clazz.get(series) + persistentLogin?.token = tokenValue + persistentLogin?.lastUsed = lastUsed + } + } + + protected Class lookupDomainClass() { + def conf = SpringSecurityUtils.securityConfig + String domainClassName = conf.rememberMe.persistentToken.domainClassName ?: '' + def clazz = SpringSecurityUtils.securityConfig.userLookup.useExternalClasses ? + Class.forName(domainClassName) : grailsApplication.getClassForName(domainClassName) + if (!clazz) { + log.error "Persistent token class not found: '{}'", domainClassName + } + clazz + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/switchuser/NullSwitchUserAuthorityChanger.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/switchuser/NullSwitchUserAuthorityChanger.groovy new file mode 100644 index 00000000000..2c163a88fd3 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/authentication/switchuser/NullSwitchUserAuthorityChanger.groovy @@ -0,0 +1,40 @@ +/* + * 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.authentication.switchuser + +import groovy.transform.CompileStatic + +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.web.authentication.switchuser.SwitchUserAuthorityChanger + +/** + * No-op implementation. + * + * @author Burt Beckwith + */ +@CompileStatic +class NullSwitchUserAuthorityChanger implements SwitchUserAuthorityChanger { + + Collection modifyGrantedAuthorities(UserDetails targetUser, + Authentication currentAuthentication, Collection authoritiesToBeGranted) { + authoritiesToBeGranted + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/DebugFilter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/DebugFilter.groovy new file mode 100644 index 00000000000..5a37c3bb95e --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/DebugFilter.groovy @@ -0,0 +1,149 @@ +/* + * 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.filter + +import groovy.transform.TypeChecked +import groovy.util.logging.Slf4j + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import jakarta.servlet.http.HttpServletResponse +import jakarta.servlet.http.HttpSession + +import org.springframework.security.web.FilterChainProxy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.util.UrlUtils +import org.springframework.web.filter.GenericFilterBean + +import jakarta.servlet.Filter +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse + +import grails.util.GrailsUtil + +/** + * Based on the package-scope org.springframework.security.config.debug.DebugFilter. + * + * @author Luke Taylor + * @author Rob Winch + * @author Burt Beckwith + */ +@Slf4j +@TypeChecked +class DebugFilter extends GenericFilterBean { + + protected static final String ALREADY_FILTERED_ATTR_NAME = DebugFilter.name + '.FILTERED' + protected static final String JAVA_LANG_EXCEPTION = 'java.lang.Exception' + protected static final int JAVA_LANG_EXCEPTION_LENGTH = JAVA_LANG_EXCEPTION.length() + + final FilterChainProxy filterChainProxy + + DebugFilter(FilterChainProxy fcp) { + filterChainProxy = fcp + } + + void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain) throws ServletException, IOException { + + HttpServletRequest request = (HttpServletRequest) req + HttpServletResponse response = (HttpServletResponse) res + + List filters = getFilters(request) + debugLog false, "Request received for '{}':\n\n{}\n\nservletPath:{}\npathInfo:{}\n\n{}", + UrlUtils.buildRequestUrl(request), request, request.servletPath, request.pathInfo, formatFilters(filters) + + if (!request.getAttribute(ALREADY_FILTERED_ATTR_NAME)) { + invokeWithWrappedRequest request, response, filterChain + } else { + filterChainProxy.doFilter request, response, filterChain + } + } + + protected void invokeWithWrappedRequest(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws IOException, ServletException { + + request.setAttribute ALREADY_FILTERED_ATTR_NAME, true + + request = new HttpServletRequestWrapper(request) { + + @Override + HttpSession getSession() { + boolean sessionExists = super.getSession(false) + HttpSession session = super.session + + if (!sessionExists) { + debugLog true, 'New HTTP session created: {}', session.id + } + + session + } + + @Override + HttpSession getSession(boolean create) { + create ? session : super.getSession(false) + } + } + + try { + filterChainProxy.doFilter request, response, filterChain + } + finally { + request.removeAttribute ALREADY_FILTERED_ATTR_NAME + } + } + + protected String formatFilters(List filters) { + StringBuilder sb = new StringBuilder('Security filter chain: ') + if (filters == null) { + sb << 'no match' + } else if (filters.empty) { + sb << "[] empty (bypassed by security='none') " + } else { + sb << '[\n' + filters.each { Filter f -> sb << ' ' << f.getClass().simpleName << '\n' } + sb << ']' + } + + sb + } + + protected List getFilters(HttpServletRequest request) { + filterChainProxy.filterChains.find({ SecurityFilterChain chain -> chain.matches(request) })?.filters + } + + protected void debugLog(boolean dumpStack, String message, Object... args) { + StringBuilder output = new StringBuilder(256) + output << '\n\n************************************************************\n\n' + output << message << '\n' + + if (dumpStack) { + StringWriter os = new StringWriter() + GrailsUtil.deepSanitize(new Exception()).printStackTrace(new PrintWriter(os)) + StringBuffer buffer = os.buffer + // Remove the exception in case it scares people. + int start = buffer.indexOf(JAVA_LANG_EXCEPTION) + buffer.replace start, start + JAVA_LANG_EXCEPTION_LENGTH, '' + output << '\nCall stack: \n' << os + } + + output << '\n\n************************************************************\n\n' + log.info output.toString(), args + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/GrailsAnonymousAuthenticationFilter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/GrailsAnonymousAuthenticationFilter.groovy new file mode 100644 index 00000000000..860dc1f43c6 --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/GrailsAnonymousAuthenticationFilter.groovy @@ -0,0 +1,79 @@ +/* + * 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.filter + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.security.authentication.AuthenticationDetailsSource +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.filter.GenericFilterBean + +import grails.plugin.springsecurity.authentication.GrailsAnonymousAuthenticationToken + +/** + * Replaces org.springframework.security.web.authentication.AnonymousAuthenticationFilter. + * + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class GrailsAnonymousAuthenticationFilter extends GenericFilterBean { + + /** Dependency injection for authenticationDetailsSource. */ + AuthenticationDetailsSource authenticationDetailsSource + + /** Dependency injection for the key. */ + String key + + @Override + void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { + applyAnonymousForThisRequest((HttpServletRequest) req) + chain.doFilter req, res + } + + protected void applyAnonymousForThisRequest(HttpServletRequest request) { + SecurityContext context = SecurityContextHolder.context + if (!context.authentication) { + context.authentication = createAuthentication(request) + log.debug "Populated SecurityContextHolder with anonymous token: '{}'", context.authentication + } else { + log.debug "SecurityContextHolder not populated with anonymous token, as it already contained: '{}'", context.authentication + } + } + + protected Authentication createAuthentication(HttpServletRequest request) { + new GrailsAnonymousAuthenticationToken(key, authenticationDetailsSource.buildDetails(request)) + } + + @Override + void afterPropertiesSet() throws ServletException { + super.afterPropertiesSet() + assert authenticationDetailsSource, 'authenticationDetailsSource must be set' + assert key, 'key must be set' + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/GrailsRememberMeAuthenticationFilter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/GrailsRememberMeAuthenticationFilter.groovy new file mode 100644 index 00000000000..9bea15ee81b --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/GrailsRememberMeAuthenticationFilter.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.filter + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.RememberMeServices +import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter +import org.springframework.security.web.savedrequest.RequestCache + +/** + * Stores a SavedRequest so remember-me autologin gets redirected to requested url. + * + * @author Burt Beckwith + */ +@CompileStatic +class GrailsRememberMeAuthenticationFilter extends RememberMeAuthenticationFilter { + + protected RequestCache requestCache + + /** Dependency injection for createSessionOnSuccess. */ + boolean createSessionOnSuccess = true + + GrailsRememberMeAuthenticationFilter(AuthenticationManager authenticationManager, RememberMeServices rememberMeServices, + RequestCache requestCache) { + super(authenticationManager, rememberMeServices) + this.requestCache = requestCache + } + + @Override + protected void onSuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, Authentication authResult) { + if (!requestCache.getRequest(request, response)) { + requestCache.saveRequest request, response + } + + try { + if (createSessionOnSuccess) { + request.session + } + } + catch (IllegalStateException ignored) { + } + } + + @Override + void afterPropertiesSet() { + super.afterPropertiesSet() + assert requestCache, 'requestCache is required' + } +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/HttpMethodOverrideDetector.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/HttpMethodOverrideDetector.groovy new file mode 100644 index 00000000000..0f54fcb26fc --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/HttpMethodOverrideDetector.groovy @@ -0,0 +1,55 @@ +/* + * 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.filter + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.util.Assert + +@CompileStatic +class HttpMethodOverrideDetector { + + /** Default method parameter: _method */ + public static final String DEFAULT_METHOD_PARAM = '_method' + + private String methodParam = DEFAULT_METHOD_PARAM + public static final String HEADER_X_HTTP_METHOD_OVERRIDE = 'X-HTTP-Method-Override' + + /** + * Set the parameter name to look for HTTP methods. + * @see #DEFAULT_METHOD_PARAM + */ + void setMethodParam(String methodParam) { + Assert.hasText(methodParam, '\'methodParam\' must not be empty') + this.methodParam = methodParam + } + + String getHttpMethodOverride(HttpServletRequest request) { + String httpMethod = request.getParameter(methodParam) + + if (httpMethod == null) { + httpMethod = request.getHeader(HEADER_X_HTTP_METHOD_OVERRIDE) + } + return httpMethod == null ? null : httpMethod.toUpperCase() + } + +} diff --git a/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilter.groovy b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilter.groovy new file mode 100644 index 00000000000..ab65e36f8bd --- /dev/null +++ b/grails-spring-security/plugin/src/main/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilter.groovy @@ -0,0 +1,144 @@ +/* + * 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.filter + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.web.util.matcher.IpAddressMatcher +import org.springframework.util.AntPathMatcher +import org.springframework.web.filter.GenericFilterBean + +import grails.plugin.springsecurity.InterceptedUrl +import grails.plugin.springsecurity.ReflectionUtils +import org.grails.web.util.WebUtils + +/** + * Blocks access to protected resources based on IP address. Sends 404 rather than + * reporting error to hide visibility of the resources. + *
+ * Supports either single IP addresses or CIDR masked patterns + * (e.g. 192.168.1.0/24, 202.24.0.0/14, 10.0.0.0/8, etc.). + * + * @author Burt Beckwith + */ +@Slf4j +@CompileStatic +class IpAddressFilter extends GenericFilterBean { + + protected static final String IPV4_LOOPBACK = '127.0.0.1' + protected static final String IPV6_LOOPBACK = '0:0:0:0:0:0:0:1' + + protected final AntPathMatcher pathMatcher = new AntPathMatcher() + + protected List restrictions + + /** Dependency injection for whether to allow localhost calls (useful for testing). TODO document. */ + boolean allowLocalhost = true + + void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req + HttpServletResponse response = (HttpServletResponse) res + + if (!isAllowed(request)) { + deny request, response + return + } + + chain.doFilter request, response + } + + protected void deny(HttpServletRequest req, HttpServletResponse res) throws IOException { + // send 404 to hide the existence of the resource + res.sendError HttpServletResponse.SC_NOT_FOUND + } + + @Override + protected void initFilterBean() { + assert restrictions, 'ipRestrictions map is required' + } + + /** + * Dependency injection for the ip/pattern restriction map. Keys are URL patterns and values + * are either single Strings or Lists of Strings + * representing IP address patterns to allow for the specified URLs. + * + * @param ipRestrictions the map + */ + void setIpRestrictions(List> ipRestrictions) { + restrictions = ipRestrictions.collect { Map entry -> + List tokens + def access = entry.access + if (access?.getClass()?.array) { + access = access as List + } + if (access instanceof Collection) { + tokens = ((Collection) access)*.toString() + } else { // String/GString + tokens = [access.toString()] + } + new InterceptedUrl(entry.pattern as String, null, ReflectionUtils.buildConfigAttributes(tokens, false)) + } + } + + protected boolean isAllowed(HttpServletRequest request) { + String ip = request.remoteAddr + if (allowLocalhost && (IPV4_LOOPBACK == ip || IPV6_LOOPBACK == ip)) { + return true + } + + String uri = request.getAttribute(WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE) + if (!uri) { + uri = request.requestURI + String contextPath = request.contextPath + if (contextPath != '/' && uri.startsWith(contextPath)) { + uri = uri.substring(contextPath.length()) + } + } + + List matching = findMatchingRules(uri) + if (!matching) { + return true + } + + for (InterceptedUrl iu in matching) { + for (ConfigAttribute ipPattern in iu.configAttributes) { + if (new IpAddressMatcher(ipPattern.attribute).matches(request)) { + return true + } + } + } + + log.warn 'disallowed request {} from {}', uri, ip + false + } + + protected List findMatchingRules(String uri) { + restrictions.findAll { InterceptedUrl iu -> pathMatcher.match iu.pattern, uri } + } +} diff --git a/grails-spring-security/plugin/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-spring-security/plugin/src/main/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 00000000000..96bc181c808 --- /dev/null +++ b/grails-spring-security/plugin/src/main/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,1088 @@ +{ + "groups": [ + { + "name": "grails.plugin.springsecurity", + "description": "Apache License 2.0 applies to this metadata. JSON files cannot include comment headers." + }, + { + "name": "grails.plugin.springsecurity.general", + "description": "General configuration properties." + }, + { + "name": "grails.plugin.springsecurity.domain-classes", + "description": "Domain class configuration properties." + }, + { + "name": "grails.plugin.springsecurity.authentication", + "description": "Authentication and authorization configuration properties." + }, + { + "name": "grails.plugin.springsecurity.login-logout", + "description": "Login and logout configuration properties." + }, + { + "name": "grails.plugin.springsecurity.password-encoding", + "description": "Password encoding configuration properties." + }, + { + "name": "grails.plugin.springsecurity.session", + "description": "Session and security context configuration properties." + }, + { + "name": "grails.plugin.springsecurity.url-mapping", + "description": "URL mapping configuration properties." + }, + { + "name": "grails.plugin.springsecurity.remember-me", + "description": "Remember-me cookie configuration properties." + }, + { + "name": "grails.plugin.springsecurity.basic-digest-auth", + "description": "Basic and digest HTTP authentication properties." + }, + { + "name": "grails.plugin.springsecurity.switch-user", + "description": "Switch user (impersonation) configuration properties." + }, + { + "name": "grails.plugin.springsecurity.port-channel", + "description": "Port mapping and secure channel configuration properties." + }, + { + "name": "grails.plugin.springsecurity.x509", + "description": "X.509 client certificate authentication properties." + }, + { + "name": "grails.plugin.springsecurity.miscellaneous", + "description": "Miscellaneous configuration properties." + } + ], + "properties": [ + { + "name": "grails.plugin.springsecurity.active", + "type": "java.lang.Boolean", + "description": "Whether the plugin is enabled.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.printStatusMessages", + "type": "java.lang.Boolean", + "description": "Whether to print status messages such as \"Configuring Spring Security Core ...\".", + "defaultValue": true, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.rejectIfNoRule", + "type": "java.lang.Boolean", + "description": "Strict mode where a request mapping is required for all resources.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.excludeSpringSecurityAutoConfiguration", + "type": "java.lang.Boolean", + "description": "Whether to automatically exclude Spring Boot security auto-configuration classes that conflict with the plugin.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.useBasicAuth", + "type": "java.lang.Boolean", + "description": "Whether to enable HTTP Basic authentication.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.useDigestAuth", + "type": "java.lang.Boolean", + "description": "Whether to enable HTTP Digest authentication.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.useSwitchUserFilter", + "type": "java.lang.Boolean", + "description": "Whether to enable the switch user filter.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.useX509", + "type": "java.lang.Boolean", + "description": "Whether to enable X.509 authentication.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.useExternalClasses", + "type": "java.lang.Boolean", + "description": "If true, use external domain classes.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.useRoleGroups", + "type": "java.lang.Boolean", + "description": "If true, enable role groups.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.general" + }, + { + "name": "grails.plugin.springsecurity.userLookup.userDomainClassName", + "type": "java.lang.String", + "description": "User class name.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.usernamePropertyName", + "type": "java.lang.String", + "description": "User class username property.", + "defaultValue": "username", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.usernameIgnoreCase", + "type": "java.lang.Boolean", + "description": "Ignore case when searching for usernamePropertyName.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.passwordPropertyName", + "type": "java.lang.String", + "description": "User class password property.", + "defaultValue": "password", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.authoritiesPropertyName", + "type": "java.lang.String", + "description": "User class role collection property.", + "defaultValue": "authorities", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.enabledPropertyName", + "type": "java.lang.String", + "description": "User class enabled property.", + "defaultValue": "enabled", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.accountExpiredPropertyName", + "type": "java.lang.String", + "description": "User class account expired property.", + "defaultValue": "accountExpired", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.accountLockedPropertyName", + "type": "java.lang.String", + "description": "User class account locked property.", + "defaultValue": "accountLocked", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.passwordExpiredPropertyName", + "type": "java.lang.String", + "description": "User class password expired property.", + "defaultValue": "passwordExpired", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.userLookup.authorityJoinClassName", + "type": "java.lang.String", + "description": "User and role many-to-many join class name.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.authority.className", + "type": "java.lang.String", + "description": "Role class name.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.authority.nameField", + "type": "java.lang.String", + "description": "Role class role name property.", + "defaultValue": "authority", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.authority.groupAuthorityNameField", + "type": "java.lang.String", + "description": "Role class authority group name property.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.requestMap.className", + "type": "java.lang.String", + "description": "Requestmap class name.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.requestMap.urlField", + "type": "java.lang.String", + "description": "Requestmap class URL pattern property.", + "defaultValue": "url", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.requestMap.configAttributeField", + "type": "java.lang.String", + "description": "Requestmap class role and token property.", + "defaultValue": "configAttribute", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.requestMap.httpMethodField", + "type": "java.lang.String", + "description": "Requestmap class HTTP method property.", + "defaultValue": "httpMethod", + "group": "grails.plugin.springsecurity.domain-classes" + }, + { + "name": "grails.plugin.springsecurity.apf.filterProcessesUrl", + "type": "java.lang.String", + "description": "Login form post URL, intercepted by Spring Security filter.", + "defaultValue": "/login/authenticate", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.apf.usernameParameter", + "type": "java.lang.String", + "description": "Login form username parameter.", + "defaultValue": "username", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.apf.passwordParameter", + "type": "java.lang.String", + "description": "Login form password parameter.", + "defaultValue": "password", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.apf.allowSessionCreation", + "type": "java.lang.Boolean", + "description": "Whether to allow authentication to create an HTTP session.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.apf.postOnly", + "type": "java.lang.Boolean", + "description": "Whether to allow only POST login requests.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.apf.continueChainBeforeSuccessfulAuthentication", + "type": "java.lang.Boolean", + "description": "Whether to continue calling subsequent filters in the filter chain.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.apf.storeLastUsername", + "type": "java.lang.Boolean", + "description": "Whether to store the login username in the HTTP session.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.failureHandler.defaultFailureUrl", + "type": "java.lang.String", + "description": "Redirect URL for failed logins.", + "defaultValue": "/login/authfail?login_error=1", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.failureHandler.ajaxAuthFailUrl", + "type": "java.lang.String", + "description": "Redirect URL for failed Ajax logins.", + "defaultValue": "/login/authfail?ajax=true", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.failureHandler.exceptionMappings", + "type": "java.util.List", + "description": "Map of AuthenticationException subclasses to redirect URLs after authentication failure.", + "defaultValue": [], + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.failureHandler.useForward", + "type": "java.lang.Boolean", + "description": "Whether to render the error page (true) or redirect (false).", + "defaultValue": false, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.failureHandler.allowSessionCreation", + "type": "java.lang.Boolean", + "description": "Whether to enable session creation to store the authentication failure exception.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.successHandler.defaultTargetUrl", + "type": "java.lang.String", + "description": "Default post-login URL if there is no saved request that triggered the login.", + "defaultValue": "/", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.successHandler.alwaysUseDefault", + "type": "java.lang.Boolean", + "description": "Whether to always redirect to successHandler.defaultTargetUrl after successful authentication.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.successHandler.targetUrlParameter", + "type": "java.lang.String", + "description": "Name of optional login form parameter that specifies destination after successful login.", + "defaultValue": "spring-security-redirect", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.successHandler.useReferer", + "type": "java.lang.Boolean", + "description": "Whether to use the HTTP Referer header to determine post-login destination.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.successHandler.ajaxSuccessUrl", + "type": "java.lang.String", + "description": "URL for redirect after successful Ajax login.", + "defaultValue": "/login/ajaxSuccess", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.auth.loginFormUrl", + "type": "java.lang.String", + "description": "URL of login page.", + "defaultValue": "/login/auth", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.auth.forceHttps", + "type": "java.lang.Boolean", + "description": "If true, redirects login page requests to HTTPS.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.auth.ajaxLoginFormUrl", + "type": "java.lang.String", + "description": "URL of Ajax login page.", + "defaultValue": "/login/authAjax", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.auth.useForward", + "type": "java.lang.Boolean", + "description": "Whether to render the login page (true) or redirect (false).", + "defaultValue": false, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.logout.afterLogoutUrl", + "type": "java.lang.String", + "description": "URL for redirect after logout.", + "defaultValue": "/", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.logout.filterProcessesUrl", + "type": "java.lang.String", + "description": "Logout URL, intercepted by Spring Security filter.", + "defaultValue": "/logoff", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.logout.handlerNames", + "type": "java.util.List", + "description": "Logout handler bean names.", + "defaultValue": [ + "rememberMeServices", + "securityContextLogoutHandler" + ], + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.logout.clearAuthentication", + "type": "java.lang.Boolean", + "description": "If true removes the Authentication from the SecurityContext to prevent issues with concurrent requests.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.logout.invalidateHttpSession", + "type": "java.lang.Boolean", + "description": "Whether to invalidate the HTTP session when logging out.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.logout.targetUrlParameter", + "type": "java.lang.String", + "description": "The querystring parameter name for the post-logout URL.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.logout.alwaysUseDefaultTargetUrl", + "type": "java.lang.Boolean", + "description": "Whether to always use afterLogoutUrl as the post-logout URL.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.logout.redirectToReferer", + "type": "java.lang.Boolean", + "description": "Whether to use the Referer header value as the post-logout URL.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.logout.postOnly", + "type": "java.lang.Boolean", + "description": "If true only POST requests will be allowed to logout.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.adh.errorPage", + "type": "java.lang.String", + "description": "Location of the 403 error page (or set to null to send a 403 error and not render a page).", + "defaultValue": "/login/denied", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.adh.ajaxErrorPage", + "type": "java.lang.String", + "description": "Location of the 403 error page for Ajax requests.", + "defaultValue": "/login/ajaxDenied", + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.adh.useForward", + "type": "java.lang.Boolean", + "description": "If true a forward will be used to render the error page, otherwise a redirect is used.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.login-logout" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.cookieName", + "type": "java.lang.String", + "description": "Remember-me cookie name.", + "defaultValue": "grails_remember_me", + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.cookieDomain", + "type": "java.lang.String", + "description": "Remember-me cookie domain.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.alwaysRemember", + "type": "java.lang.Boolean", + "description": "Whether to always remember users.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.tokenValiditySeconds", + "type": "java.lang.Integer", + "description": "Remember-me token validity in seconds.", + "defaultValue": 1209600, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.parameter", + "type": "java.lang.String", + "description": "Remember-me parameter name.", + "defaultValue": "remember-me", + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.key", + "type": "java.lang.String", + "description": "Key used to identify remember-me tokens.", + "defaultValue": "grailsRocks", + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.persistent", + "type": "java.lang.Boolean", + "description": "Whether to use persistent remember-me tokens.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.persistentToken.domainClassName", + "type": "java.lang.String", + "description": "Persistent token domain class name.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.persistentToken.seriesLength", + "type": "java.lang.Integer", + "description": "Persistent token series length.", + "defaultValue": 16, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.persistentToken.tokenLength", + "type": "java.lang.Integer", + "description": "Persistent token token length.", + "defaultValue": 16, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.useSecureCookie", + "type": "java.lang.Boolean", + "description": "Whether to use a secure remember-me cookie.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.rememberMe.createSessionOnSuccess", + "type": "java.lang.Boolean", + "description": "Whether to create a session after remember-me login.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.remember-me" + }, + { + "name": "grails.plugin.springsecurity.basic.realmName", + "type": "java.lang.String", + "description": "Realm name for HTTP Basic authentication.", + "defaultValue": "Grails Realm", + "group": "grails.plugin.springsecurity.basic-digest-auth" + }, + { + "name": "grails.plugin.springsecurity.basic.credentialsCharset", + "type": "java.lang.String", + "description": "Credentials charset for HTTP Basic authentication.", + "defaultValue": "UTF-8", + "group": "grails.plugin.springsecurity.basic-digest-auth" + }, + { + "name": "grails.plugin.springsecurity.digest.realmName", + "type": "java.lang.String", + "description": "Realm name for HTTP Digest authentication.", + "defaultValue": "Grails Realm", + "group": "grails.plugin.springsecurity.basic-digest-auth" + }, + { + "name": "grails.plugin.springsecurity.digest.key", + "type": "java.lang.String", + "description": "Key for HTTP Digest authentication.", + "defaultValue": "changeme", + "group": "grails.plugin.springsecurity.basic-digest-auth" + }, + { + "name": "grails.plugin.springsecurity.digest.nonceValiditySeconds", + "type": "java.lang.Integer", + "description": "Digest nonce validity in seconds.", + "defaultValue": 300, + "group": "grails.plugin.springsecurity.basic-digest-auth" + }, + { + "name": "grails.plugin.springsecurity.digest.passwordAlreadyEncoded", + "type": "java.lang.Boolean", + "description": "Whether digest passwords are already encoded.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.basic-digest-auth" + }, + { + "name": "grails.plugin.springsecurity.digest.createAuthenticatedToken", + "type": "java.lang.Boolean", + "description": "Whether to create authenticated tokens for digest auth.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.basic-digest-auth" + }, + { + "name": "grails.plugin.springsecurity.digest.useCleartextPasswords", + "type": "java.lang.Boolean", + "description": "Whether to use cleartext passwords for digest auth.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.basic-digest-auth" + }, + { + "name": "grails.plugin.springsecurity.switchUser.switchUserUrl", + "type": "java.lang.String", + "description": "URL used to switch users.", + "defaultValue": "/login/impersonate", + "group": "grails.plugin.springsecurity.switch-user" + }, + { + "name": "grails.plugin.springsecurity.switchUser.exitUserUrl", + "type": "java.lang.String", + "description": "URL used to exit switched user.", + "defaultValue": "/logout/impersonate", + "group": "grails.plugin.springsecurity.switch-user" + }, + { + "name": "grails.plugin.springsecurity.switchUser.targetUrl", + "type": "java.lang.String", + "description": "Target URL after switching user.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.switch-user" + }, + { + "name": "grails.plugin.springsecurity.switchUser.switchFailureUrl", + "type": "java.lang.String", + "description": "Failure URL for switch user.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.switch-user" + }, + { + "name": "grails.plugin.springsecurity.switchUser.usernameParameter", + "type": "java.lang.String", + "description": "Username parameter for switch user.", + "defaultValue": "username", + "group": "grails.plugin.springsecurity.switch-user" + }, + { + "name": "grails.plugin.springsecurity.portMapper.httpPort", + "type": "java.lang.Integer", + "description": "HTTP port for port mapping.", + "defaultValue": 8080, + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.portMapper.httpsPort", + "type": "java.lang.Integer", + "description": "HTTPS port for port mapping.", + "defaultValue": 8443, + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.secureChannel.definition", + "type": "java.util.List", + "description": "Secure channel definition rules.", + "defaultValue": [], + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.secureChannel.useHeaderCheckChannelSecurity", + "type": "java.lang.Boolean", + "description": "Whether to use header based channel security.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.secureChannel.secureHeaderName", + "type": "java.lang.String", + "description": "Header name indicating secure channel.", + "defaultValue": "X-Forwarded-Proto", + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.secureChannel.secureHeaderValue", + "type": "java.lang.String", + "description": "Header value indicating secure channel.", + "defaultValue": "http", + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.secureChannel.secureConfigAttributeKeyword", + "type": "java.lang.String", + "description": "Config attribute keyword for secure channel.", + "defaultValue": "REQUIRES_SECURE_CHANNEL", + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.secureChannel.insecureHeaderName", + "type": "java.lang.String", + "description": "Header name indicating insecure channel.", + "defaultValue": "X-Forwarded-Proto", + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.secureChannel.insecureHeaderValue", + "type": "java.lang.String", + "description": "Header value indicating insecure channel.", + "defaultValue": "https", + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.secureChannel.insecureConfigAttributeKeyword", + "type": "java.lang.String", + "description": "Config attribute keyword for insecure channel.", + "defaultValue": "REQUIRES_INSECURE_CHANNEL", + "group": "grails.plugin.springsecurity.port-channel" + }, + { + "name": "grails.plugin.springsecurity.x509.continueFilterChainOnUnsuccessfulAuthentication", + "type": "java.lang.Boolean", + "description": "Whether to continue filter chain when X.509 authentication fails.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.x509" + }, + { + "name": "grails.plugin.springsecurity.x509.subjectDnRegex", + "type": "java.lang.String", + "description": "Regex for extracting username from subject DN.", + "defaultValue": "CN=(.*?)(?:,|$)", + "group": "grails.plugin.springsecurity.x509" + }, + { + "name": "grails.plugin.springsecurity.x509.subjectDnClosure", + "type": "groovy.lang.Closure", + "description": "Closure to extract principal from subject DN.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.x509" + }, + { + "name": "grails.plugin.springsecurity.x509.checkForPrincipalChanges", + "type": "java.lang.Boolean", + "description": "Whether to check for principal changes.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.x509" + }, + { + "name": "grails.plugin.springsecurity.x509.invalidateSessionOnPrincipalChange", + "type": "java.lang.Boolean", + "description": "Whether to invalidate the session when the principal changes.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.x509" + }, + { + "name": "grails.plugin.springsecurity.x509.throwExceptionWhenTokenRejected", + "type": "java.lang.Boolean", + "description": "Whether to throw an exception when the token is rejected.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.x509" + }, + { + "name": "grails.plugin.springsecurity.ajaxHeader", + "type": "java.lang.String", + "description": "Header name sent by Ajax library, used to detect Ajax.", + "defaultValue": "X-Requested-With", + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.ajaxCheckClosure", + "type": "groovy.lang.Closure", + "description": "An optional closure that can determine if a request is Ajax.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.ipRestrictions", + "type": "java.util.List", + "description": "List of IP addresses allowed by the IP restriction filter.", + "defaultValue": [], + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.beanTypeResolverClass", + "type": "java.lang.String", + "description": "Bean type resolver class name.", + "defaultValue": "grails.plugin.springsecurity.BeanTypeResolver", + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.gsp.layoutAuth", + "type": "java.lang.String", + "description": "Layout name for auth views.", + "defaultValue": "main", + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.gsp.layoutDenied", + "type": "java.lang.String", + "description": "Layout name for denied views.", + "defaultValue": "main", + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.redirectStrategy.contextRelative", + "type": "java.lang.Boolean", + "description": "If true, the redirect URL will be the value after the request context path.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.url-mapping" + }, + { + "name": "grails.plugin.springsecurity.fii.alwaysReauthenticate", + "type": "java.lang.Boolean", + "description": "If true, re-authenticates when there is an Authentication in the SecurityContext.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.fii.rejectPublicInvocations", + "type": "java.lang.Boolean", + "description": "Disallow URL access when there is no request mapping.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.fii.validateConfigAttributes", + "type": "java.lang.Boolean", + "description": "Whether to check that all ConfigAttribute instances are valid at startup.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.fii.publishAuthorizationSuccess", + "type": "java.lang.Boolean", + "description": "Whether to publish an AuthorizedEvent after successful access check.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.fii.observeOncePerRequest", + "type": "java.lang.Boolean", + "description": "If false, allow checks to happen multiple times.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.anon.key", + "type": "java.lang.String", + "description": "anonymousProcessingFilter key.", + "defaultValue": "foo", + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.atr.anonymousClass", + "type": "java.lang.String", + "description": "Anonymous token class.", + "defaultValue": "grails.plugin.springsecurity.authentication.GrailsAnonymousAuthenticationToken", + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.atr.rememberMeClass", + "type": "java.lang.String", + "description": "Remember-me token class.", + "defaultValue": "org.springframework.security.authentication.RememberMeAuthenticationToken", + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.useHttpSessionEventPublisher", + "type": "java.lang.Boolean", + "description": "If true, an HttpSessionEventPublisher will be configured.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.cacheUsers", + "type": "java.lang.Boolean", + "description": "If true, logins are cached using an EhCache.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.useSecurityEventListener", + "type": "java.lang.Boolean", + "description": "If true, configure SecurityEventListener.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.dao.reflectionSaltSourceProperty", + "type": "java.lang.String", + "description": "Which property to use for the reflection-based salt source.", + "defaultValue": null, + "group": "grails.plugin.springsecurity.password-encoding" + }, + { + "name": "grails.plugin.springsecurity.password.algorithm", + "type": "java.lang.String", + "description": "Password encoding algorithm.", + "defaultValue": "bcrypt", + "group": "grails.plugin.springsecurity.password-encoding" + }, + { + "name": "grails.plugin.springsecurity.password.encodeHashAsBase64", + "type": "java.lang.Boolean", + "description": "Whether to Base64 encode password hashes.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.password-encoding" + }, + { + "name": "grails.plugin.springsecurity.password.bcrypt.logrounds", + "type": "java.lang.Integer", + "description": "BCrypt log rounds.", + "defaultValue": 10, + "group": "grails.plugin.springsecurity.password-encoding" + }, + { + "name": "grails.plugin.springsecurity.password.hash.iterations", + "type": "java.lang.Integer", + "description": "Hashing iterations.", + "defaultValue": 10000, + "group": "grails.plugin.springsecurity.password-encoding" + }, + { + "name": "grails.plugin.springsecurity.dao.hideUserNotFoundExceptions", + "type": "java.lang.Boolean", + "description": "If true, throws BadCredentialsException for both bad username and bad password.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.requestCache.createSession", + "type": "java.lang.Boolean", + "description": "Whether caching SavedRequest can trigger the creation of a session.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.useSessionFixationPrevention", + "type": "java.lang.Boolean", + "description": "Whether to use session fixation prevention.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.sessionFixationPrevention.migrate", + "type": "java.lang.Boolean", + "description": "Whether to migrate the session on authentication.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.sessionFixationPrevention.alwaysCreateSession", + "type": "java.lang.Boolean", + "description": "Whether to always create a session for session fixation prevention.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.roleHierarchy", + "type": "java.lang.String", + "description": "Hierarchical role definition.", + "defaultValue": "", + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.voterNames", + "type": "java.util.List", + "description": "Bean names of voters.", + "defaultValue": [ + "authenticatedVoter", + "roleVoter", + "closureVoter" + ], + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.providerNames", + "type": "java.util.List", + "description": "Bean names of authentication providers.", + "defaultValue": [ + "daoAuthenticationProvider", + "anonymousAuthenticationProvider", + "rememberMeAuthenticationProvider" + ], + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.afterInvocationManagerProviderNames", + "type": "java.util.List", + "description": "AfterInvocationManager provider bean names.", + "defaultValue": [], + "group": "grails.plugin.springsecurity.authentication" + }, + { + "name": "grails.plugin.springsecurity.securityConfigType", + "type": "java.lang.String", + "description": "Type of request mapping to use, one of Annotation, Requestmap, or InterceptUrlMap.", + "defaultValue": "Annotation", + "group": "grails.plugin.springsecurity.url-mapping" + }, + { + "name": "grails.plugin.springsecurity.controllerAnnotations.lowercase", + "type": "java.lang.Boolean", + "description": "Whether to do URL comparisons using lowercase.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.url-mapping" + }, + { + "name": "grails.plugin.springsecurity.controllerAnnotations.staticRules", + "type": "java.util.List", + "description": "Extra rules that cannot be mapped using annotations.", + "defaultValue": [], + "group": "grails.plugin.springsecurity.url-mapping" + }, + { + "name": "grails.plugin.springsecurity.interceptUrlMap", + "type": "java.util.List", + "description": "Request mapping definition when using InterceptUrlMap.", + "defaultValue": [], + "group": "grails.plugin.springsecurity.url-mapping" + }, + { + "name": "grails.plugin.springsecurity.registerLoggerListener", + "type": "java.lang.Boolean", + "description": "If true, registers a LoggerListener that logs interceptor-related application events.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.scr.allowSessionCreation", + "type": "java.lang.Boolean", + "description": "Whether to allow creating a session in the securityContextRepository bean.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.scr.disableUrlRewriting", + "type": "java.lang.Boolean", + "description": "Whether to disable URL rewriting (and the jsessionid attribute).", + "defaultValue": true, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.scr.springSecurityContextKey", + "type": "java.lang.String", + "description": "The HTTP session key to store the SecurityContext under.", + "defaultValue": "HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY", + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.scpf.forceEagerSessionCreation", + "type": "java.lang.Boolean", + "description": "Whether to eagerly create a session in the securityContextRepository bean.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.sch.strategyName", + "type": "java.lang.String", + "description": "The strategy to use for storing the SecurityContext.", + "defaultValue": "SecurityContextHolder.MODE_THREADLOCAL", + "group": "grails.plugin.springsecurity.session" + }, + { + "name": "grails.plugin.springsecurity.debug.useFilter", + "type": "java.lang.Boolean", + "description": "Whether to use the DebugFilter to log request debug information to the console.", + "defaultValue": false, + "group": "grails.plugin.springsecurity.miscellaneous" + }, + { + "name": "grails.plugin.springsecurity.providerManager.eraseCredentialsAfterAuthentication", + "type": "java.lang.Boolean", + "description": "Whether to remove the password from the Authentication and its child objects after successful authentication.", + "defaultValue": true, + "group": "grails.plugin.springsecurity.authentication" + } + ] +} diff --git a/grails-spring-security/plugin/src/main/resources/META-INF/spring.factories b/grails-spring-security/plugin/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..b778c86de0f --- /dev/null +++ b/grails-spring-security/plugin/src/main/resources/META-INF/spring.factories @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Automatically exclude Spring Boot security auto-configurations that conflict +# with the Grails Spring Security plugin's bean definitions. +# See: SecurityAutoConfigurationExcluder javadoc for the full list and rationale. +org.springframework.boot.autoconfigure.AutoConfigurationImportFilter=\ + grails.plugin.springsecurity.SecurityAutoConfigurationExcluder diff --git a/grails-spring-security/plugin/src/main/scripts/s2-create-persistent-token.groovy b/grails-spring-security/plugin/src/main/scripts/s2-create-persistent-token.groovy new file mode 100644 index 00000000000..d71bd664181 --- /dev/null +++ b/grails-spring-security/plugin/src/main/scripts/s2-create-persistent-token.groovy @@ -0,0 +1,46 @@ +/* + * 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. + */ + +import grails.codegen.model.Model + +description 'Creates a persistent token domain class for the Spring Security Core plugin', { + usage ''' +grails s2-create-persistent-token [DOMAIN CLASS NAME] + +Example: grails s2-create-persistent-token com.yourapp.PersistentLogin +''' + + argument name: 'Domain class name', description: 'The domain class full name with package' +} + +String fullClassName = args[0] +Model model = model(fullClassName) + +addStatus "\nCreating persistent token class $fullClassName" + +render template: template('PersistentLogin.groovy.template'), + destination: file("grails-app/domain/$model.packagePath/${model.simpleName}.groovy"), + model: model, overwrite: false + +file('grails-app/conf/application.groovy').withWriterAppend { BufferedWriter writer -> + writer.newLine() + writer.writeLine 'grails.plugin.springsecurity.rememberMe.persistent = true' + writer.writeLine "grails.plugin.springsecurity.rememberMe.persistentToken.domainClassName = '$fullClassName'" + writer.newLine() +} diff --git a/grails-spring-security/plugin/src/main/scripts/s2-create-role-hierarchy-entry.groovy b/grails-spring-security/plugin/src/main/scripts/s2-create-role-hierarchy-entry.groovy new file mode 100644 index 00000000000..1e9a88219bc --- /dev/null +++ b/grails-spring-security/plugin/src/main/scripts/s2-create-role-hierarchy-entry.groovy @@ -0,0 +1,50 @@ +/* + * 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. + */ + +import grails.codegen.model.Model + +/** + * @author fpape + * @author Burt Beckwith + */ + +description 'Creates a domain class for a persistent role hierarchy for the Spring Security Core plugin', { + usage ''' +grails s2-create-role-hierarchy-entry [DOMAIN CLASS NAME] + +Example: grails s2-create-role-hierarchy-entry com.yourapp.RoleHierarchyEntry +''' + + argument name: 'Domain class name', description: 'The domain class full name with package' +} + +String fullClassName = args[0] +Model model = model(fullClassName) + +addStatus "\nCreating role hierarchy entry class $fullClassName" + +render template: template('RoleHierarchyEntry.groovy.template'), + destination: file("grails-app/domain/$model.packagePath/${model.simpleName}.groovy"), + model: model, overwrite: false + +file('grails-app/conf/application.groovy').withWriterAppend { BufferedWriter writer -> + writer.newLine() + writer.writeLine "grails.plugin.springsecurity.roleHierarchyEntryClassName = '$fullClassName'" + writer.newLine() +} diff --git a/grails-spring-security/plugin/src/main/scripts/s2-quickstart.groovy b/grails-spring-security/plugin/src/main/scripts/s2-quickstart.groovy new file mode 100644 index 00000000000..9cf1be805e2 --- /dev/null +++ b/grails-spring-security/plugin/src/main/scripts/s2-quickstart.groovy @@ -0,0 +1,287 @@ +/* + * 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. + */ + + +import groovy.transform.Field + +import grails.codegen.model.Model + +@Field String usageMessage = ''' + grails s2-quickstart [requestmap-class-name] [--groupClassName=group-class-name] +or grails s2-quickstart --uiOnly + +Example: grails s2-quickstart com.yourapp User Role +Example: grails s2-quickstart com.yourapp User Role --groupClassName=RoleGroup +Example: grails s2-quickstart com.yourapp Person Authority Requestmap +Example: grails s2-quickstart --uiOnly +''' + +@Field Map templateAttributes +@Field boolean uiOnly +@Field boolean salt + +description 'Creates domain classes and updates config settings for the Spring Security plugin', { + + usage usageMessage + + argument name: 'Domain class package', description: 'The package to use for the domain classes', required: false + argument name: 'User class name', description: 'The name of the User/Person class', required: false + argument name: 'Role class name', description: 'The name of the Role class', required: false + argument name: 'Requestmap class name', description: 'The name of the Requestmap class', required: false + + flag name: 'groupClassName', description: 'If specified, role/group classes will also be generated using the flag value as the role-group name' + flag name: 'uiOnly', description: 'If specified, no domain classes are created but the plugin settings are initialized (useful with LDAP, Mock, Shibboleth, etc.)' +} + +Model userModel +Model roleModel +Model requestmapModel +Model groupModel +uiOnly = flag('uiOnly') +salt = flag('salt') +if (uiOnly) { + addStatus '\nConfiguring Spring Security; not generating domain classes' +} else { + + if (args.size() < 3) { + error 'Usage:' + usageMessage + return false + } + + String packageName = args[0] + String groupClassName = flag('groupClassName') + String groupClassNameMessage = '' + if (groupClassName) { + groupModel = model(packageName + '.' + groupClassName) + groupClassNameMessage = ", and role/group classes for '" + groupModel.simpleName + "'" + } + + userModel = model(packageName + '.' + args[1]) + roleModel = model(packageName + '.' + args[2]) + + String message = "Creating User class '" + userModel.simpleName + "'" + if (4 == args.size()) { + requestmapModel = model(packageName + '.' + args[3]) + message += ", Role class '" + roleModel.simpleName + "', and Requestmap class '" + requestmapModel.simpleName + "'" + groupClassNameMessage + } else { + message += " and Role class '" + roleModel.simpleName + "'" + groupClassNameMessage + } + message += " in package '" + packageName + "'" + addStatus message + + templateAttributes = [ + packageName : userModel.packageName, + userClassName : userModel.simpleName, + userClassProperty : userModel.modelName, + roleClassName : roleModel.simpleName, + roleClassProperty : roleModel.modelName, + requestmapClassName: requestmapModel?.simpleName, + groupClassName : groupModel?.simpleName, + groupClassProperty : groupModel?.modelName] + + createDomains userModel, roleModel, requestmapModel, groupModel +} + +updateConfig userModel?.simpleName, roleModel?.simpleName, requestmapModel?.simpleName, userModel?.packageName, groupModel != null + +if (uiOnly) { + addStatus ''' +************************************************************ +* Your grails-app/conf/application.groovy has been updated * +* with security settings; please verify that the * +* values are correct. * +************************************************************ +''' +} else { + addStatus ''' +************************************************************ +* Created security-related domain classes. Your * +* grails-app/conf/application.groovy has been updated with * +* the class names of the configured domain classes; * +* please verify that the values are correct. * +************************************************************ +''' +} + +private Map extractVersion(String versionString) { + def arr = versionString.split('\\.') + def v = [mayor: 0, minor: 0, bug: 0] + try { + if (arr.size() >= 1) { + v.mayor = arr[0].toInteger() + } + if (arr.size() >= 2) { + v.minor = arr[1].toInteger() + } + if (arr.size() >= 3) { + v.bug = arr[2].toInteger() + } + } catch (Exception e) { + v = [mayor: 0, minor: 0, bug: 0] + } + v +} + +private boolean versionAfterOrEqualsToThreshold(String threshold, String value) { + if (value == null) { + return false + } + if (value.startsWith(threshold)) { + return true + } + + def va = extractVersion(value) + def vb = extractVersion(threshold) + def l = [va, vb] + l.sort { Map a, Map b -> + def compare = a.mayor <=> b.mayor + if (compare != 0) { + return compare + } + compare = a.minor <=> b.minor + if (compare != 0) { + return compare + } + a.bug <=> b.bug + } + def sortedValue = l[0].collect { k, v -> v }.join('.') + threshold.startsWith(sortedValue) +} + +private void createDomains(Model userModel, Model roleModel, Model requestmapModel, Model groupModel) { + + def props = new Properties() + file("gradle.properties")?.withInputStream { props.load(it) } + + final threshold = '6.0.10' + + boolean gormVersionAfterThreshold = versionAfterOrEqualsToThreshold(threshold, props.gormVersion ?: props.getProperty("gorm.version")) + + if (gormVersionAfterThreshold) { + generateFile 'PersonWithoutInjection', userModel.packagePath, userModel.simpleName + if (salt) { + generateFile 'PersonPasswordEncoderListenerWithSalt', userModel.packagePath, userModel.simpleName, "${userModel.simpleName}PasswordEncoderListener", 'src/main/groovy' + } else { + generateFile 'PersonPasswordEncoderListener', userModel.packagePath, userModel.simpleName, "${userModel.simpleName}PasswordEncoderListener", 'src/main/groovy' + } + def beansList = [[import: "import ${userModel.packageName}.${userModel.simpleName}PasswordEncoderListener", definition: "${userModel.propertyName}PasswordEncoderListener(${userModel.simpleName}PasswordEncoderListener)"]] + addBeans(beansList, 'grails-app/conf/spring/resources.groovy') + + } else { + if (salt) { + generateFile 'PersonWithSalt', userModel.packagePath, userModel.simpleName + } else { + generateFile 'Person', userModel.packagePath, userModel.simpleName + } + } + + generateFile 'Authority', roleModel.packagePath, roleModel.simpleName + generateFile 'PersonAuthority', roleModel.packagePath, userModel.simpleName + roleModel.simpleName + + if (requestmapModel) { + generateFile 'Requestmap', requestmapModel.packagePath, requestmapModel.simpleName + } + + if (groupModel) { + generateFile 'AuthorityGroup', groupModel.packagePath, groupModel.simpleName + generateFile 'PersonAuthorityGroup', groupModel.packagePath, userModel.simpleName + groupModel.simpleName + generateFile 'AuthorityGroupAuthority', groupModel.packagePath, groupModel.simpleName + roleModel.simpleName + } +} + +private void updateConfig(String userClassName, String roleClassName, String requestmapClassName, String packageName, boolean useRoleGroups) { + + file('grails-app/conf/application.groovy').withWriterAppend { BufferedWriter writer -> + writer.newLine() + writer.newLine() + writer.writeLine '// Added by the Spring Security Core plugin:' + if (!uiOnly) { + writer.writeLine "grails.plugin.springsecurity.userLookup.userDomainClassName = '${packageName}.$userClassName'" + writer.writeLine "grails.plugin.springsecurity.userLookup.authorityJoinClassName = '${packageName}.$userClassName$roleClassName'" + writer.writeLine "grails.plugin.springsecurity.authority.className = '${packageName}.$roleClassName'" + } + if (useRoleGroups) { + writer.writeLine "grails.plugin.springsecurity.authority.groupAuthorityNameField = 'authorities'" + writer.writeLine 'grails.plugin.springsecurity.useRoleGroups = true' + } + if (requestmapClassName) { + writer.writeLine "grails.plugin.springsecurity.requestMap.className = '${packageName}.$requestmapClassName'" + writer.writeLine "grails.plugin.springsecurity.securityConfigType = 'Requestmap'" + } + writer.writeLine 'grails.plugin.springsecurity.controllerAnnotations.staticRules = [' + writer.writeLine "\t[pattern: '/', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/error', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/index', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/index.gsp', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/shutdown', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/assets/**', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/**/js/**', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/**/css/**', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/**/images/**', access: ['permitAll']]," + writer.writeLine "\t[pattern: '/**/favicon.ico', access: ['permitAll']]" + writer.writeLine ']' + writer.newLine() + + writer.writeLine 'grails.plugin.springsecurity.filterChain.chainMap = [' + writer.writeLine "\t[pattern: '/assets/**', filters: 'none']," + writer.writeLine "\t[pattern: '/**/js/**', filters: 'none']," + writer.writeLine "\t[pattern: '/**/css/**', filters: 'none']," + writer.writeLine "\t[pattern: '/**/images/**', filters: 'none']," + writer.writeLine "\t[pattern: '/**/favicon.ico', filters: 'none']," + writer.writeLine "\t[pattern: '/**', filters: 'JOINED_FILTERS']" + writer.writeLine ']' + writer.newLine() + } +} + +private void generateFile(String templateName, String packagePath, String className, String fileName = null, String folder = 'grails-app/domain') { + render template(templateName + '.groovy.template'), + file("${folder}/$packagePath/${fileName ?: className}.groovy"), + templateAttributes, false +} + +private void addBeans(List beans, String pathname) { + def f = new File(pathname) + def lines = [] + beans.each { Map bean -> + lines << bean.import + } + if (f.exists()) { + f.eachLine { line, nb -> + lines << line + if (line.contains('beans = {')) { + beans.each { Map bean -> + lines << ' ' + bean.definition + } + } + } + } else { + lines << 'beans = {' + beans.each { Map bean -> + lines << ' ' + bean.definition + } + lines << '}' + } + + f.withWriter('UTF-8') { writer -> + lines.each { String line -> + writer.write "${line}${System.lineSeparator()}" + } + } +} diff --git a/grails-spring-security/plugin/src/main/templates/Authority.groovy.template b/grails-spring-security/plugin/src/main/templates/Authority.groovy.template new file mode 100644 index 00000000000..12cb8d21df6 --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/Authority.groovy.template @@ -0,0 +1,23 @@ +package ${packageName} + +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@EqualsAndHashCode(includes='authority') +@ToString(includes='authority', includeNames=true, includePackage=false) +class ${roleClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + String authority + + static constraints = { + authority nullable: false, blank: false, unique: true + } + + static mapping = { + cache true + } +} diff --git a/grails-spring-security/plugin/src/main/templates/AuthorityGroup.groovy.template b/grails-spring-security/plugin/src/main/templates/AuthorityGroup.groovy.template new file mode 100644 index 00000000000..7e8651325ff --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/AuthorityGroup.groovy.template @@ -0,0 +1,27 @@ +package ${packageName} + +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@EqualsAndHashCode(includes='name') +@ToString(includes='name', includeNames=true, includePackage=false) +class ${groupClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + String name + + Set<${roleClassName}> getAuthorities() { + (${groupClassName}${roleClassName}.findAllBy${groupClassName}(this) as List<${groupClassName}${roleClassName}>)*.${roleClassProperty} as Set<${roleClassName}> + } + + static constraints = { + name nullable: false, blank: false, unique: true + } + + static mapping = { + cache true + } +} diff --git a/grails-spring-security/plugin/src/main/templates/AuthorityGroupAuthority.groovy.template b/grails-spring-security/plugin/src/main/templates/AuthorityGroupAuthority.groovy.template new file mode 100644 index 00000000000..48ef0234c8a --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/AuthorityGroupAuthority.groovy.template @@ -0,0 +1,86 @@ +package ${packageName} + +import grails.gorm.DetachedCriteria +import groovy.transform.ToString +import org.codehaus.groovy.util.HashCodeHelper +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@ToString(cache=true, includeNames=true, includePackage=false) +class ${groupClassName}${roleClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + ${groupClassName} ${groupClassProperty} + ${roleClassName} ${roleClassProperty} + + @Override + boolean equals(other) { + if (other instanceof ${groupClassName}${roleClassName}) { + other.${roleClassProperty}Id == ${roleClassProperty}?.id && other.${groupClassProperty}Id == ${groupClassProperty}?.id + } + } + + @Override + int hashCode() { + int hashCode = HashCodeHelper.initHash() + if (${groupClassProperty}) { + hashCode = HashCodeHelper.updateHash(hashCode, ${groupClassProperty}.id) + } + if (${roleClassProperty}) { + hashCode = HashCodeHelper.updateHash(hashCode, ${roleClassProperty}.id) + } + hashCode + } + + static ${groupClassName}${roleClassName} get(long ${groupClassProperty}Id, long ${roleClassProperty}Id) { + criteriaFor(${groupClassProperty}Id, ${roleClassProperty}Id).get() + } + + static boolean exists(long ${groupClassProperty}Id, long ${roleClassProperty}Id) { + criteriaFor(${groupClassProperty}Id, ${roleClassProperty}Id).count() + } + + private static DetachedCriteria<${groupClassName}${roleClassName}> criteriaFor(long ${groupClassProperty}Id, long ${roleClassProperty}Id) { + ${groupClassName}${roleClassName}.where { + ${groupClassProperty} == ${groupClassName}.load(${groupClassProperty}Id) && + ${roleClassProperty} == ${roleClassName}.load(${roleClassProperty}Id) + } + } + + static ${groupClassName}${roleClassName} create(${groupClassName} ${groupClassProperty}, ${roleClassName} ${roleClassProperty}, boolean flush = false) { + def instance = new ${groupClassName}${roleClassName}(${groupClassProperty}: ${groupClassProperty}, ${roleClassProperty}: ${roleClassProperty}) + instance.save(flush: flush) + instance + } + + static boolean remove(${groupClassName} rg, ${roleClassName} r) { + if (rg != null && r != null) { + ${groupClassName}${roleClassName}.where { ${groupClassProperty} == rg && ${roleClassProperty} == r }.deleteAll() + } + } + + static int removeAll(${roleClassName} r) { + r == null ? 0 : ${groupClassName}${roleClassName}.where { ${roleClassProperty} == r }.deleteAll() as int + } + + static int removeAll(${groupClassName} rg) { + rg == null ? 0 : ${groupClassName}${roleClassName}.where { ${groupClassProperty} == rg }.deleteAll() as int + } + + static constraints = { + ${groupClassProperty} nullable: false + ${roleClassProperty} nullable: false, validator: { ${roleClassName} r, ${groupClassName}${roleClassName} rg -> + if (rg.${groupClassProperty}?.id) { + if (${groupClassName}${roleClassName}.exists(rg.${groupClassProperty}.id, r.id)) { + return ['roleGroup.exists'] + } + } + } + } + + static mapping = { + id composite: ['${groupClassProperty}', '${roleClassProperty}'] + version false + } +} diff --git a/grails-spring-security/plugin/src/main/templates/PersistentLogin.groovy.template b/grails-spring-security/plugin/src/main/templates/PersistentLogin.groovy.template new file mode 100644 index 00000000000..39ac60e4ee8 --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/PersistentLogin.groovy.template @@ -0,0 +1,31 @@ +package ${packageName} + +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@EqualsAndHashCode(includes=['series', 'username']) +@ToString(includes=['series', 'username'], cache=true, includeNames=true, includePackage=false) +class ${className} implements Serializable { + + private static final long serialVersionUID = 1 + + String series + String username + String token + Date lastUsed + + static constraints = { + series nullable: false, maxSize: 64 + token nullable: false, maxSize: 64 + username nullable: false, maxSize: 64, index:'username_idx' + lastUsed nullable: false + } + + static mapping = { + table 'persistent_login' + id name: 'series', generator: 'assigned' + version false + } +} diff --git a/grails-spring-security/plugin/src/main/templates/Person.groovy.template b/grails-spring-security/plugin/src/main/templates/Person.groovy.template new file mode 100644 index 00000000000..5974d2cd969 --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/Person.groovy.template @@ -0,0 +1,53 @@ +package ${packageName} + +import grails.plugin.springsecurity.SpringSecurityService +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@EqualsAndHashCode(includes='username') +@ToString(includes='username', includeNames=true, includePackage=false) +class ${userClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + SpringSecurityService springSecurityService + + String username + String password + boolean enabled = true + boolean accountExpired + boolean accountLocked + boolean passwordExpired + + Set<${groupClassName ?: roleClassName}> getAuthorities() { + (${userClassName}${groupClassName ?: roleClassName}.findAllBy${userClassName}(this) as List<${userClassName}${groupClassName ?: roleClassName}>)*.${groupClassProperty ?: roleClassProperty} as Set<${groupClassName ?: roleClassName}> + } + + def beforeInsert() { + encodePassword() + } + + def beforeUpdate() { + if (isDirty('password')) { + encodePassword() + } + } + + protected void encodePassword() { + password = springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password + } + + static transients = ['springSecurityService'] + + static constraints = { + password nullable: false, blank: false, password: true + username nullable: false, blank: false, unique: true + } + + static mapping = { + ${'User' == userClassName ? "table name: '`user`'" : ''} + password column: '`password`' + } +} diff --git a/grails-spring-security/plugin/src/main/templates/PersonAuthority.groovy.template b/grails-spring-security/plugin/src/main/templates/PersonAuthority.groovy.template new file mode 100644 index 00000000000..940b9fba760 --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/PersonAuthority.groovy.template @@ -0,0 +1,86 @@ +package ${packageName} + +import grails.gorm.DetachedCriteria +import grails.compiler.GrailsCompileStatic +import groovy.transform.ToString +import org.codehaus.groovy.util.HashCodeHelper + +@GrailsCompileStatic +@ToString(cache=true, includeNames=true, includePackage=false) +class ${userClassName}${roleClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + ${userClassName} ${userClassProperty} + ${roleClassName} ${roleClassProperty} + + @Override + boolean equals(other) { + if (other instanceof ${userClassName}${roleClassName}) { + other.${userClassProperty}Id == ${userClassProperty}?.id && other.${roleClassProperty}Id == ${roleClassProperty}?.id + } + } + + @Override + int hashCode() { + int hashCode = HashCodeHelper.initHash() + if (${userClassProperty}) { + hashCode = HashCodeHelper.updateHash(hashCode, ${userClassProperty}.id) + } + if (${roleClassProperty}) { + hashCode = HashCodeHelper.updateHash(hashCode, ${roleClassProperty}.id) + } + hashCode + } + + static ${userClassName}${roleClassName} get(long ${userClassProperty}Id, long ${roleClassProperty}Id) { + criteriaFor(${userClassProperty}Id, ${roleClassProperty}Id).get() + } + + static boolean exists(long ${userClassProperty}Id, long ${roleClassProperty}Id) { + criteriaFor(${userClassProperty}Id, ${roleClassProperty}Id).count() + } + + private static DetachedCriteria<${userClassName}${roleClassName}> criteriaFor(long ${userClassProperty}Id, long ${roleClassProperty}Id) { + ${userClassName}${roleClassName}.where { + ${userClassProperty} == ${userClassName}.load(${userClassProperty}Id) && + ${roleClassProperty} == ${roleClassName}.load(${roleClassProperty}Id) + } + } + + static ${userClassName}${roleClassName} create(${userClassName} ${userClassProperty}, ${roleClassName} ${roleClassProperty}, boolean flush = false) { + def instance = new ${userClassName}${roleClassName}(${userClassProperty}: ${userClassProperty}, ${roleClassProperty}: ${roleClassProperty}) + instance.save(flush: flush) + instance + } + + static boolean remove(${userClassName} u, ${roleClassName} r) { + if (u != null && r != null) { + ${userClassName}${roleClassName}.where { ${userClassProperty} == u && ${roleClassProperty} == r }.deleteAll() + } + } + + static int removeAll(${userClassName} u) { + u == null ? 0 : ${userClassName}${roleClassName}.where { ${userClassProperty} == u }.deleteAll() as int + } + + static int removeAll(${roleClassName} r) { + r == null ? 0 : ${userClassName}${roleClassName}.where { ${roleClassProperty} == r }.deleteAll() as int + } + + static constraints = { + ${userClassProperty} nullable: false + ${roleClassProperty} nullable: false, validator: { ${roleClassName} r, ${userClassName}${roleClassName} ur -> + if (ur.${userClassProperty}?.id) { + if (${userClassName}${roleClassName}.exists(ur.${userClassProperty}.id, r.id)) { + return ['userRole.exists'] + } + } + } + } + + static mapping = { + id composite: ['${userClassProperty}', '${roleClassProperty}'] + version false + } +} diff --git a/grails-spring-security/plugin/src/main/templates/PersonAuthorityGroup.groovy.template b/grails-spring-security/plugin/src/main/templates/PersonAuthorityGroup.groovy.template new file mode 100644 index 00000000000..f79fba6581a --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/PersonAuthorityGroup.groovy.template @@ -0,0 +1,86 @@ +package ${packageName} + +import grails.gorm.DetachedCriteria +import groovy.transform.ToString +import org.codehaus.groovy.util.HashCodeHelper +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@ToString(cache=true, includeNames=true, includePackage=false) +class ${userClassName}${groupClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + ${userClassName} ${userClassProperty} + ${groupClassName} ${groupClassProperty} + + @Override + boolean equals(other) { + if (other instanceof ${userClassName}${groupClassName}) { + other.${userClassProperty}Id == ${userClassProperty}?.id && other.${groupClassProperty}Id == ${groupClassProperty}?.id + } + } + + @Override + int hashCode() { + int hashCode = HashCodeHelper.initHash() + if (${userClassProperty}) { + hashCode = HashCodeHelper.updateHash(hashCode, ${userClassProperty}.id) + } + if (${groupClassProperty}) { + hashCode = HashCodeHelper.updateHash(hashCode, ${groupClassProperty}.id) + } + hashCode + } + + static ${userClassName}${groupClassName} get(long ${userClassProperty}Id, long ${groupClassProperty}Id) { + criteriaFor(${userClassProperty}Id, ${groupClassProperty}Id).get() + } + + static boolean exists(long ${userClassProperty}Id, long ${groupClassProperty}Id) { + criteriaFor(${userClassProperty}Id, ${groupClassProperty}Id).count() + } + + private static DetachedCriteria<${userClassName}${groupClassName}> criteriaFor(long ${userClassProperty}Id, long ${groupClassProperty}Id) { + ${userClassName}${groupClassName}.where { + ${userClassProperty} == ${userClassName}.load(${userClassProperty}Id) && + ${groupClassProperty} == ${groupClassName}.load(${groupClassProperty}Id) + } + } + + static ${userClassName}${groupClassName} create(${userClassName} ${userClassProperty}, ${groupClassName} ${groupClassProperty}, boolean flush = false) { + def instance = new ${userClassName}${groupClassName}(${userClassProperty}: ${userClassProperty}, ${groupClassProperty}: ${groupClassProperty}) + instance.save(flush: flush) + instance + } + + static boolean remove(${userClassName} u, ${groupClassName} rg) { + if (u != null && rg != null) { + ${userClassName}${groupClassName}.where { ${userClassProperty} == u && ${groupClassProperty} == rg }.deleteAll() + } + } + + static int removeAll(${userClassName} u) { + u == null ? 0 : ${userClassName}${groupClassName}.where { ${userClassProperty} == u }.deleteAll() as int + } + + static int removeAll(${groupClassName} rg) { + rg == null ? 0 : ${userClassName}${groupClassName}.where { ${groupClassProperty} == rg }.deleteAll() as int + } + + static constraints = { + ${groupClassProperty} nullable: false + ${userClassProperty} nullable: false, validator: { ${userClassName} u, ${userClassName}${groupClassName} ug -> + if (ug.${groupClassProperty}?.id) { + if (${userClassName}${groupClassName}.exists(u.id, ug.${groupClassProperty}.id)) { + return ['userGroup.exists'] + } + } + } + } + + static mapping = { + id composite: ['${groupClassProperty}', '${userClassProperty}'] + version false + } +} diff --git a/grails-spring-security/plugin/src/main/templates/PersonPasswordEncoderListener.groovy.template b/grails-spring-security/plugin/src/main/templates/PersonPasswordEncoderListener.groovy.template new file mode 100644 index 00000000000..368e594b20c --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/PersonPasswordEncoderListener.groovy.template @@ -0,0 +1,40 @@ +package ${packageName} + +import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent +import org.grails.datastore.mapping.engine.event.PreInsertEvent +import org.grails.datastore.mapping.engine.event.PreUpdateEvent +import grails.events.annotation.gorm.Listener + +import grails.plugin.springsecurity.SpringSecurityService +import groovy.transform.CompileStatic +import org.springframework.beans.factory.annotation.Autowired + +@CompileStatic +class ${userClassName}PasswordEncoderListener { + + @Autowired + SpringSecurityService springSecurityService + + @Listener(${userClassName}) + void onPreInsertEvent(PreInsertEvent event) { + encodePasswordForEvent(event) + } + + @Listener(${userClassName}) + void onPreUpdateEvent(PreUpdateEvent event) { + encodePasswordForEvent(event) + } + + private void encodePasswordForEvent(AbstractPersistenceEvent event) { + if (event.entityObject instanceof ${userClassName}) { + ${userClassName} u = event.entityObject as ${userClassName} + if (u.password && ((event instanceof PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) { + event.getEntityAccess().setProperty('password', encodePassword(u.password)) + } + } + } + + private String encodePassword(String password) { + springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password + } +} diff --git a/grails-spring-security/plugin/src/main/templates/PersonPasswordEncoderListenerWithSalt.groovy.template b/grails-spring-security/plugin/src/main/templates/PersonPasswordEncoderListenerWithSalt.groovy.template new file mode 100644 index 00000000000..2a0abc342a0 --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/PersonPasswordEncoderListenerWithSalt.groovy.template @@ -0,0 +1,64 @@ +package ${packageName} + +import grails.plugin.springsecurity.SpringSecurityService +import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent +import org.grails.datastore.mapping.engine.event.PreInsertEvent +import org.grails.datastore.mapping.engine.event.PreUpdateEvent +import grails.events.annotation.gorm.Listener +import org.springframework.beans.factory.annotation.Autowired +import groovy.transform.CompileStatic +import jakarta.annotation.PostConstruct + +@CompileStatic +class ${userClassName}PasswordEncoderListener { + + String algorithm + + String reflectionSaltProperty + + @Autowired + SpringSecurityService springSecurityService + + @PostConstruct + void setup() { + algorithm = springSecurityService.grailsApplication.config.getProperty('grails.plugin.springsecurity.password.algorithm') + reflectionSaltProperty = springSecurityService.grailsApplication.config.getProperty('grails.plugin.springsecurity.dao.reflectionSaltSourceProperty') + } + + @Listener(${userClassName}) + void onPreInsertEvent(PreInsertEvent event) { + encodePasswordForEvent(event) + } + + @Listener(${userClassName}) + void onPreUpdateEvent(PreUpdateEvent event) { + encodePasswordForEvent(event) + } + + private void encodePasswordForEvent(AbstractPersistenceEvent event) { + if (event.entityObject instanceof ${userClassName}) { + ${userClassName} u = event.entityObject as ${userClassName} + if (u.password && ((event instanceof PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) { + event.getEntityAccess().setProperty('password', encodePassword(u)) + } + } + } + + private String encodePassword(TestUser u) { + springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(u.password, salt(u)) : u.password + } + + private def salt(TestUser u) { + if ( !springSecurityService ) { + return null + } + + if ( algorithm == 'bcrypt' || algorithm == 'pbkdf2' ) { + return null + } + if ( reflectionSaltProperty ) { + return u.getProperty(reflectionSaltProperty) + } + null + } +} diff --git a/grails-spring-security/plugin/src/main/templates/PersonWithSalt.groovy.template b/grails-spring-security/plugin/src/main/templates/PersonWithSalt.groovy.template new file mode 100644 index 00000000000..9d8dbe0f48e --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/PersonWithSalt.groovy.template @@ -0,0 +1,71 @@ +package ${packageName} + +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import grails.plugin.springsecurity.SpringSecurityService +import grails.compiler.GrailsCompileStatic +import groovy.transform.CompileDynamic + +@GrailsCompileStatic +@EqualsAndHashCode(includes='username') +@ToString(includes='username', includeNames=true, includePackage=false) +class ${userClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + SpringSecurityService springSecurityService + + String username + String password + boolean enabled = true + boolean accountExpired + boolean accountLocked + boolean passwordExpired + + Set<${groupClassName ?: roleClassName}> getAuthorities() { + (${userClassName}${groupClassName ?: roleClassName}.findAllBy${userClassName}(this) as List<${userClassName}${groupClassName ?: roleClassName}>)*.${groupClassProperty ?: roleClassProperty} as Set<${groupClassName ?: roleClassName}> + } + + def beforeInsert() { + encodePassword() + } + + def beforeUpdate() { + if (isDirty('password')) { + encodePassword() + } + } + + protected void encodePassword() { + password = springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password, salt()) : password + } + + @CompileDynamic + def salt() { + if ( !springSecurityService ) { + return null + } + def algorithm = springSecurityService.grailsApplication.config.getProperty('grails.plugin.springsecurity.password.algorithm') + if ( algorithm == 'bcrypt' || algorithm == 'pbkdf2' ) { + return null + } + + def reflectionSaltProperty = springSecurityService.grailsApplication.config.getProperty('grails.plugin.springsecurity.dao.reflectionSaltSourceProperty') + if ( reflectionSaltProperty ) { + return getProperty(reflectionSaltProperty) + } + null + } + + static transients = ['springSecurityService'] + + static constraints = { + password nullable: false, blank: false, password: true + username nullable: false, blank: false, unique: true + } + + static mapping = { + ${'User' == userClassName ? "table name: '`user`'" : ''} + password column: '`password`' + } +} diff --git a/grails-spring-security/plugin/src/main/templates/PersonWithoutInjection.groovy.template b/grails-spring-security/plugin/src/main/templates/PersonWithoutInjection.groovy.template new file mode 100644 index 00000000000..aff850c2a3f --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/PersonWithoutInjection.groovy.template @@ -0,0 +1,34 @@ +package ${packageName} + +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@EqualsAndHashCode(includes='username') +@ToString(includes='username', includeNames=true, includePackage=false) +class ${userClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + String username + String password + boolean enabled = true + boolean accountExpired + boolean accountLocked + boolean passwordExpired + + Set<${groupClassName ?: roleClassName}> getAuthorities() { + (${userClassName}${groupClassName ?: roleClassName}.findAllBy${userClassName}(this) as List<${userClassName}${groupClassName ?: roleClassName}>)*.${groupClassProperty ?: roleClassProperty} as Set<${groupClassName ?: roleClassName}> + } + + static constraints = { + password nullable: false, blank: false, password: true + username nullable: false, blank: false, unique: true + } + + static mapping = { + ${'User' == userClassName ? "table name: '`user`'" : ''} + password column: '`password`' + } +} diff --git a/grails-spring-security/plugin/src/main/templates/Requestmap.groovy.template b/grails-spring-security/plugin/src/main/templates/Requestmap.groovy.template new file mode 100644 index 00000000000..b4c713e27db --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/Requestmap.groovy.template @@ -0,0 +1,29 @@ +package ${packageName} + +import org.springframework.http.HttpMethod + +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@EqualsAndHashCode(includes=['configAttribute', 'httpMethod', 'url']) +@ToString(includes=['configAttribute', 'httpMethod', 'url'], cache=true, includeNames=true, includePackage=false) +class ${requestmapClassName} implements Serializable { + + private static final long serialVersionUID = 1 + + String configAttribute + HttpMethod httpMethod + String url + + static constraints = { + configAttribute nullable: false, blank: false + httpMethod nullable: true + url nullable: false, blank: false, unique: 'httpMethod' + } + + static mapping = { + cache true + } +} diff --git a/grails-spring-security/plugin/src/main/templates/RoleHierarchyEntry.groovy.template b/grails-spring-security/plugin/src/main/templates/RoleHierarchyEntry.groovy.template new file mode 100644 index 00000000000..23379a26a9c --- /dev/null +++ b/grails-spring-security/plugin/src/main/templates/RoleHierarchyEntry.groovy.template @@ -0,0 +1,23 @@ +package ${packageName} + +import groovy.transform.EqualsAndHashCode +import groovy.transform.ToString +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +@EqualsAndHashCode(includes='entry') +@ToString(includes='entry', includeNames=true, includePackage=false) +class ${className} implements Serializable { + + private static final long serialVersionUID = 1 + + String entry + + static constraints = { + entry nullable: false, blank: false, unique: true + } + + static mapping = { + cache true + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/AbstractUnitSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/AbstractUnitSpec.groovy new file mode 100644 index 00000000000..882af2b5125 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/AbstractUnitSpec.groovy @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.plugin.springsecurity + +import spock.lang.Specification + +import org.springframework.security.access.vote.AuthenticatedVoter +import org.springframework.security.access.vote.RoleVoter +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler + +import grails.plugin.springsecurity.web.SecurityRequestHolder +import grails.testing.web.GrailsWebUnitTest + +/** + * @author Burt Beckwith + */ +abstract class AbstractUnitSpec extends Specification implements GrailsWebUnitTest { + + void setupSpec() { + defineBeans { + webExpressionHandler(DefaultWebSecurityExpressionHandler) + roleVoter(RoleVoter) + authenticatedVoter(AuthenticatedVoter) + } + } + + void setup() { + ReflectionUtils.application = SpringSecurityUtils.application = grailsApplication + } + + void cleanup() { + SecurityContextHolder.clearContext() + SecurityRequestHolder.reset() + SecurityTestUtils.logout() + SpringSecurityUtils.resetSecurityConfig() + ReflectionUtils.application = null + SpringSecurityUtils.application = null + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ReflectionUtilsSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ReflectionUtilsSpec.groovy new file mode 100644 index 00000000000..e29045d988d --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ReflectionUtilsSpec.groovy @@ -0,0 +1,216 @@ +/* + * 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 org.springframework.http.HttpMethod + +/** + * @author Burt Beckwith + */ +class ReflectionUtilsSpec extends AbstractUnitSpec { + + void 'set config property'() { + when: + def foo = SpringSecurityUtils.securityConfig.foo + + then: + foo instanceof Map + !foo + + when: + ReflectionUtils.setConfigProperty 'foo', 'bar' + + then: + 'bar' == SpringSecurityUtils.securityConfig.foo + } + + void 'get config property'() { + when: + def d = ReflectionUtils.getConfigProperty('a.b.c') + + then: + d instanceof Map + !d + + when: + ReflectionUtils.setConfigProperty 'a.b.c', 'd' + + then: + 'd' == ReflectionUtils.getConfigProperty('a.b.c') + 'd' == SpringSecurityUtils.securityConfig.a.b.c + } + + void 'get role authority'() { + when: + String authorityName = 'ROLE_FOO' + def role = [authority: authorityName] + + then: + authorityName == ReflectionUtils.getRoleAuthority(role) + } + + void 'get requestmap url'() { + when: + String url = '/admin/**' + def requestmap = [url: url] + + then: + url == ReflectionUtils.getRequestmapUrl(requestmap) + } + + void 'get requestmap config attribute'() { + when: + String configAttribute = 'ROLE_ADMIN' + def requestmap = [configAttribute: configAttribute] + + then: + configAttribute == ReflectionUtils.getRequestmapConfigAttribute(requestmap) + } + + void 'as list'() { + when: + def list = ReflectionUtils.asList(null) + + then: + list instanceof List + !list + + when: + list = ReflectionUtils.asList([1, 2, 3]) + + then: + list instanceof List + 3 == list.size() + + when: + String[] strings = ['a', 'b'] + list = ReflectionUtils.asList(strings) + + then: + list instanceof List + 2 == list.size() + } + + void 'split map'() { + when: + def listOfMaps = [ + [pattern: '/foo', access: ['a', 'b']], + [pattern: '/user/**', access: ['c'], httpMethod: HttpMethod.POST], + [pattern: '/bar/**', access: 'd', httpMethod: 'GET'] + ] + List split = ReflectionUtils.splitMap(listOfMaps) + + then: + 3 == split.size() + + and: + split[0].pattern == '/foo' + split[0].configAttributes*.toString() == ['a', 'b'] + !split[0].httpMethod + + and: + split[1].pattern == '/user/**' + split[1].configAttributes*.toString() == ['c'] + split[1].httpMethod == HttpMethod.POST + + and: + split[2].pattern == '/bar/**' + split[2].configAttributes*.toString() == ['d'] + split[2].httpMethod == HttpMethod.GET + } + + void 'get grails serverURL when set'() { + when: + String url = 'http://somewhere.org' + ReflectionUtils.application.config.grails.serverURL = url + + then: + ReflectionUtils.getGrailsServerURL() == url + } + + void 'get grails serverURL when not set'() { + when: + ReflectionUtils.application.config.grails.serverURL = null + + then: + ReflectionUtils.getGrailsServerURL() == null + } + + void 'findFilterNames coerces boolean settings without throwing (including null values)'() { + when: 'settings are supplied as booleans, strings, or null (e.g. a key absent from config)' + ConfigObject config = [ + 'filterChain.filterNames' : 'dummy', + 'secureChannel.definition': secureChannelValue, + 'ipRestrictions' : ipRestrictionsValue, + 'useX509' : x509Value, + 'useDigestAuth' : digestAuthValue, + 'useBasicAuth' : basicAuthValue, + 'useSwitchUserFilter' : switchUserFilterValue + ] as ConfigObject + ReflectionUtils.findFilterChainNames(config) + + then: 'no exception is thrown - Groovy 5 rejects `null as boolean`, so null must coerce to false' + noExceptionThrown() + + where: + secureChannelValue | ipRestrictionsValue | x509Value | digestAuthValue | basicAuthValue | switchUserFilterValue + true | false | false | false | true | false + 'true' | 'false' | 'false' | 'false' | 'true' | 'false' + true | 'false' | null | null | 'true' | null + null | null | null | null | null | null + } + + void "findFilterNames includes the x509 filter only when useX509 coerces to true (value=#x509Value)"() { + given: 'a config that does not list filter names explicitly, so the boolean flags drive the chain' + ConfigObject config = ['useX509': x509Value] as ConfigObject + + when: + SortedMap names = ReflectionUtils.findFilterChainNames(config) + + then: 'x509ProcessingFilter is present only when the flag is truthy; null coerces to false without throwing' + names.containsValue('x509ProcessingFilter') == x509Enabled + + where: + x509Value || x509Enabled + true || true + false || false + 'true' || true + null || false + '' || false + } + + void 'get intercept url map with empty httpMethod config'() { + when: + List> interceptUrlMap = [ + [ + pattern : '/secure/**', + access : ['IS_AUTHENTICATED_ANONYMOUSLY'], + httpMethod: null //This is the case with missing config. + ] + ] + List interceptedUrls = ReflectionUtils.splitMap(interceptUrlMap) + + then: + notThrown(GroovyRuntimeException) + interceptedUrls?.size() == 1 + interceptedUrls.first()?.pattern == '/secure/**' + interceptedUrls.first().configAttributes?.size() == 1 + interceptedUrls.first().configAttributes.first().attribute == 'IS_AUTHENTICATED_ANONYMOUSLY' + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy new file mode 100644 index 00000000000..ebc7c6d95be --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityAutoConfigurationExcluderSpec.groovy @@ -0,0 +1,273 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package grails.plugin.springsecurity + +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll + +import org.springframework.core.env.Environment + +/** + * Tests for {@link SecurityAutoConfigurationExcluder}. + * + * Verifies that Spring Boot 4 servlet security auto-configuration classes that + * conflict with the Grails Spring Security plugin are filtered out during the + * auto-configuration discovery phase. + */ +class SecurityAutoConfigurationExcluderSpec extends Specification { + + @Subject + SecurityAutoConfigurationExcluder excluder = new SecurityAutoConfigurationExcluder() + + @Unroll + def "match excludes conflicting auto-configuration: #className"() { + given: + def autoConfigs = [className] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'the conflicting auto-configuration is excluded (false = filtered out)' + !results[0] + + where: + className << [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration', + 'org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration', + ] + } + + @Unroll + def "match preserves non-security auto-configuration: #className"() { + given: + def autoConfigs = [className] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'non-security auto-configurations pass through (true = included)' + results[0] + + where: + className << [ + 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration', + 'org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration', + 'org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration', + 'org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration', + 'org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration', + ] + } + + @Unroll + def "match preserves Spring Boot 3 (pre-move) security auto-configuration class names: #className"() { + given: 'these legacy class names are no longer registered as auto-configurations in Spring Boot 4' + def autoConfigs = [className] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'the filter is conservative and only excludes the verified Spring Boot 4 names' + results[0] + + where: + className << [ + 'org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration', + 'org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration', + 'org.springframework.boot.actuate.autoconfigure.security.servlet.ManagementWebSecurityAutoConfiguration', + ] + } + + @Unroll + def "match preserves reactive Spring Boot 4 security auto-configuration: #className"() { + given: 'reactive variants are not excluded; they are guarded by ConditionalOnWebApplication(REACTIVE)' + def autoConfigs = [className] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'the filter is servlet-only and lets reactive variants pass through' + results[0] + + where: + className << [ + 'org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.reactive.ReactiveWebSecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.actuate.web.reactive.ReactiveManagementWebSecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.rsocket.RSocketSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientAutoConfiguration', + 'org.springframework.boot.security.oauth2.client.autoconfigure.reactive.ReactiveOAuth2ClientWebSecurityAutoConfiguration', + 'org.springframework.boot.security.oauth2.server.resource.autoconfigure.reactive.ReactiveOAuth2ResourceServerAutoConfiguration', + ] + } + + def "match handles mixed array of included and excluded auto-configurations"() { + given: + def autoConfigs = [ + 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + 'org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: + results[0] // DataSource - included + !results[1] // SecurityAutoConfiguration - excluded + results[2] // Jackson - included + !results[3] // SecurityFilterAutoConfiguration - excluded + results[4] // DispatcherServlet - included + } + + def "match handles empty array"() { + given: + def autoConfigs = [] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: + results.length == 0 + } + + def "match handles null metadata parameter gracefully"() { + given: 'autoConfigurationMetadata is null (not used by this filter)' + def autoConfigs = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'still works correctly' + !results[0] + } + + def "getExcludedAutoConfigurations returns all 11 known conflicting classes"() { + when: + def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations + + then: + excluded.size() == 11 + excluded.contains('org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration') + excluded.contains('org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration') + excluded.contains('org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration') + excluded.contains('org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration') + excluded.contains('org.springframework.boot.security.autoconfigure.actuate.web.servlet.ManagementWebSecurityAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.client.autoconfigure.OAuth2ClientAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.client.autoconfigure.servlet.OAuth2ClientWebSecurityAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.server.resource.autoconfigure.servlet.OAuth2ResourceServerAutoConfiguration') + excluded.contains('org.springframework.boot.security.saml2.autoconfigure.Saml2RelyingPartyAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerAutoConfiguration') + excluded.contains('org.springframework.boot.security.oauth2.server.authorization.autoconfigure.servlet.OAuth2AuthorizationServerJwtAutoConfiguration') + } + + def "getExcludedAutoConfigurations returns unmodifiable set"() { + when: + def excluded = SecurityAutoConfigurationExcluder.excludedAutoConfigurations + excluded.add('some.new.AutoConfiguration') + + then: + thrown(UnsupportedOperationException) + } + + def "match allows all auto-configurations when disabled via environment property"() { + given: + def env = Mock(Environment) + env.getProperty(SecurityAutoConfigurationExcluder.ENABLED_PROPERTY, Boolean, true) >> false + excluder.environment = env + + and: + def autoConfigs = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + 'org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration', + 'org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'all auto-configurations pass through when filter is disabled' + results[0] + results[1] + results[2] + } + + def "match excludes by default when environment has the property set to true"() { + given: + def env = Mock(Environment) + env.getProperty(SecurityAutoConfigurationExcluder.ENABLED_PROPERTY, Boolean, true) >> true + excluder.environment = env + + and: + def autoConfigs = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'exclusion is active by default' + !results[0] + } + + def "match excludes by default when no environment is set"() { + given: 'excluder without environment (e.g. unit test usage)' + def autoConfigs = [ + 'org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration', + ] as String[] + + when: + def results = excluder.match(autoConfigs, null) + + then: 'exclusion is active by default' + !results[0] + } + + def "spring.factories registers the filter correctly"() { + when: 'enumerating all spring.factories resources on the classpath' + def resources = getClass().classLoader.getResources('META-INF/spring.factories') + def allContents = resources.collect { it.text } + + then: 'at least one spring.factories exists' + !allContents.isEmpty() + + and: 'one of them registers SecurityAutoConfigurationExcluder as an AutoConfigurationImportFilter' + allContents.any { content -> + content.contains('org.springframework.boot.autoconfigure.AutoConfigurationImportFilter') && + content.contains('grails.plugin.springsecurity.SecurityAutoConfigurationExcluder') + } + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy new file mode 100644 index 00000000000..d67f73264ce --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityEventListenerSpec.groovy @@ -0,0 +1,106 @@ +/* + * 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 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 +import org.springframework.security.authentication.event.AuthenticationSuccessEvent +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent +import org.springframework.security.core.userdetails.User +import org.springframework.security.web.authentication.switchuser.AuthenticationSwitchUserEvent + +/** + * Unit tests for SecurityEventListener. + * + * @author Burt Beckwith + */ +class SecurityEventListenerSpec extends AbstractUnitSpec { + + private SecurityEventListener listener = new SecurityEventListener() + private closures = new ConfigObject() + + void setup() { + SpringSecurityUtils.securityConfig = closures + } + + void 'Test handling InteractiveAuthenticationSuccessEvent'() { + when: + boolean called = false + closures.onInteractiveAuthenticationSuccessEvent = { e, appCtx -> called = true } + + listener.onApplicationEvent(new InteractiveAuthenticationSuccessEvent( + new TestingAuthenticationToken('', ''), getClass())) + + then: + called + } + + void 'Test handling AbstractAuthenticationFailureEvent'() { + when: + boolean called = false + closures.onAbstractAuthenticationFailureEvent = { e, appCtx -> called = true } + + listener.onApplicationEvent new AuthenticationFailureBadCredentialsEvent( + new TestingAuthenticationToken('', ''), new BadCredentialsException('bad credentials')) + + then: + called + } + + void 'Test handling AuthenticationSuccessEvent'() { + when: + boolean called = false + closures.onAuthenticationSuccessEvent = { e, appCtx -> called = true } + + listener.onApplicationEvent(new AuthenticationSuccessEvent( + new TestingAuthenticationToken('', ''))) + + then: + called + } + + void 'Test handling AuthorizationEvent'() { + when: + boolean called = false + closures.onAuthorizationEvent = { e, appCtx -> called = true } + + listener.onApplicationEvent new AuthorizationEvent({ new TestingAuthenticationToken('', '') }, 42, new AuthorizationDecision(true)) + + then: + called + } + + void 'Test handling AuthenticationSwitchUserEvent'() { + when: + boolean called = false + closures.onAuthenticationSwitchUserEvent = { e, appCtx -> called = true } + + def authentication = SecurityTestUtils.authenticate(['ROLE_FOO']) + def targetUser = new User('username', 'password', true, true, true, + true, authentication.authorities) + + listener.onApplicationEvent(new AuthenticationSwitchUserEvent(authentication, targetUser)) + + then: + called + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityTagLibUnitSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityTagLibUnitSpec.groovy new file mode 100644 index 00000000000..1f48dc448dd --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SecurityTagLibUnitSpec.groovy @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package grails.plugin.springsecurity + +import spock.lang.Ignore +import spock.lang.Specification + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.web.access.WebInvocationPrivilegeEvaluator + +import grails.testing.web.taglib.TagLibUnitTest +import org.grails.web.mapping.DefaultLinkGenerator + +class SecurityTagLibUnitSpec extends Specification implements TagLibUnitTest { + + static final String CONTEXT_PATH = '/context-path' + static final String CONTROLLER = 'admin' + static final String ACTION = 'index' + static final String TAG_BODY = 'body' + String currentUri = null + boolean isAllowed = true + + def setup() { + defineBeans({ + linkGenerator(DefaultLinkGenerator, '', CONTEXT_PATH) + }) + currentUri = null + tagLib.webInvocationPrivilegeEvaluator = new WebInvocationPrivilegeEvaluator() { + + @Override + boolean isAllowed(String uri, Authentication authentication) { + currentUri = uri + return isAllowed + } + + @Override + boolean isAllowed(String contextPath, String uri, String method, Authentication authentication) { + currentUri = uri + return isAllowed + } + } + def mockSpringSecurityService = Mock(SpringSecurityService) + mockSpringSecurityService.getAuthentication() >> { + Authentication auth = new UsernamePasswordAuthenticationToken("user", "credentials", + [new SimpleGrantedAuthority("ROLE_ADMIN")]) + return auth + } + tagLib.springSecurityService = mockSpringSecurityService + } + + def cleanup() { + } + + void "test no access if not authorized"() { + setup: + def mockSpringSecurityService = Mock(SpringSecurityService) + mockSpringSecurityService.getAuthentication() >> { + return new UsernamePasswordAuthenticationToken("user", "credentials") + } + tagLib.springSecurityService = mockSpringSecurityService + + when: + String rendered = accessTag() + + then: + !rendered + } + + void "test no access if disallowed by webInvocationPrivilegeEvaluator"() { + setup: + isAllowed = false + + when: + String rendered = accessTag() + + then: + !rendered + } + + void "test access if authenticated"() { + when: + String rendered = accessTag() + + then: + rendered == TAG_BODY + } + + void "test remove context path from uri generated by linkGenerator"() { + given: + tagLib.serverContextPath = CONTEXT_PATH + + when: + String rendered = accessTag() + + then: + rendered == TAG_BODY + currentUri == "/${CONTROLLER}/${ACTION}" + } + + @Ignore + // https://github.com/apache/grails-core/issues/13690 + void "test no removal of context path from uri generated by linkGenerator with no contextPath config"() { + given: + tagLib.serverContextPath = null + + when: + accessTag() + + then: + currentUri == "${CONTEXT_PATH}/${CONTROLLER}/${ACTION}" + } + + void "test remove context path from uri generated by linkGenerator if contextPath is defined in request"() { + given: + tagLib.serverContextPath = null + + and: + request.contextPath = CONTEXT_PATH + + when: + String rendered = accessTag() + + then: + rendered == TAG_BODY + currentUri == "/${CONTROLLER}/${ACTION}" + } + + private String accessTag() { + return tagLib.access(controller: CONTROLLER, action: ACTION, TAG_BODY) + } +} \ No newline at end of file diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityServiceSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityServiceSpec.groovy new file mode 100644 index 00000000000..2de6a2f8941 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityServiceSpec.groovy @@ -0,0 +1,107 @@ +/* + * 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 org.springframework.core.annotation.AnnotationUtils +import org.springframework.security.authentication.AuthenticationTrustResolverImpl +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder as SCH +import org.springframework.security.core.userdetails.User + +import grails.gorm.transactions.Transactional +import org.grails.datastore.mapping.reflect.ClassPropertyFetcher + +/** + * Unit tests for SpringSecurityService. + * + * @author Burt Beckwith + */ +class SpringSecurityServiceSpec extends AbstractUnitSpec { + + private SpringSecurityService service = new SpringSecurityService() + + void 'transactional'() { + expect: + !ClassPropertyFetcher.forClass(SpringSecurityService).getPropertyValue('transactional') + SpringSecurityService.methods.any { AnnotationUtils.findAnnotation(it, Transactional) } + } + + void 'principal authenticated'() { + expect: + !service.principal + + when: + authenticate 'role1' + + then: + service.principal + } + + void 'encodePassword'() { + when: + service.passwordEncoder = [encode: { String pwd -> pwd + '_encoded' }] + + then: + 'passw0rd_encoded' == service.encodePassword('passw0rd') + } + + void 'clearCachedRequestmaps'() { + when: + boolean resetCalled = false + service.objectDefinitionSource = [reset: { -> resetCalled = true }] + + service.clearCachedRequestmaps() + + then: + resetCalled + } + + void 'getAuthentication'() { + expect: + !service.authentication?.principal + + when: + authenticate 'role1' + + then: + service.authentication + } + + void 'isLoggedIn'() { + when: + service.authenticationTrustResolver = new AuthenticationTrustResolverImpl() + + then: + !service.isLoggedIn() + + when: + authenticate 'role1' + + then: + service.isLoggedIn() + } + + private void authenticate(roles) { + def authorities = SpringSecurityUtils.parseAuthoritiesString(roles) + def principal = new User('username', 'password', true, true, true, true, authorities) + def authentication = new TestingAuthenticationToken(principal, null, authorities) + authentication.authenticated = true + SCH.context.authentication = authentication + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy new file mode 100644 index 00000000000..a4dfc3632fa --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/SpringSecurityUtilsSpec.groovy @@ -0,0 +1,446 @@ +/* + * 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 grails.plugin.springsecurity.web.GrailsSecurityFilterChain +import grails.plugin.springsecurity.web.SecurityRequestHolder +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl +import org.springframework.security.config.http.SecurityFiltersMapper +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.web.FilterChainProxy +import org.springframework.security.web.savedrequest.DefaultSavedRequest +import org.springframework.web.filter.GenericFilterBean +import spock.lang.Unroll + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse + +/** + * @author Burt Beckwith + */ +class SpringSecurityUtilsSpec extends AbstractUnitSpec { + + void setupSpec() { + defineBeans { + dummyFilter(DummyFilter) + firstDummy(DummyFilter) + secondDummy(DummyFilter) + securityFilterChains(ArrayList) + springSecurityFilterChain(FilterChainProxy, ref('securityFilterChains')) + } + + applicationContext.securityFilterChains << new GrailsSecurityFilterChain('/**', []) + } + + void setup() { + SpringSecurityUtils.application = grailsApplication + SpringSecurityUtils.registerFilter 'firstDummy', 100 + SpringSecurityUtils.registerFilter 'secondDummy', 200 + def configured = SpringSecurityUtils.configuredOrderedFilters + SpringSecurityUtils.orderedFilters.each { order, name -> configured[order] = applicationContext.getBean(name) } + SecurityRequestHolder.set request, null + + applicationContext.securityFilterChains[0].filters.clear() + applicationContext.securityFilterChains[0].filters << applicationContext.firstDummy << applicationContext.secondDummy + } + + @Unroll("noFiltersIsApplied returns #expected for pattern #pattern, chainMap => [pattern: #chainMapPattern, filters: #chainMapFilters]") + void "verifies noFiltersIsApplied "(String chainMapPattern, String chainMapFilters, String pattern, boolean expected) { + expect: + SpringSecurityUtils.noFilterIsApplied([[pattern: chainMapPattern, filters: chainMapFilters]], pattern) == expected + + where: + chainMapPattern | chainMapFilters | pattern | expected + '/assets/**' | 'JOINED_FILTERS' | '/assets/**' | false + '/assets/**' | 'none' | '/assets/**' | true + '/foo' | 'none' | '/assets/**' | false + } + + void 'should retain existing chainmap'() { + when: + SpringSecurityUtils.clientRegisterFilter 'dummyFilter', 101 + def filters = applicationContext.securityFilterChains[0].filters + + then: + filters.size() == 3 + filters[1] == applicationContext.dummyFilter + } + + void 'should add as first in existing chainmap'() { + when: + SpringSecurityUtils.clientRegisterFilter 'dummyFilter', 99 + def filters = applicationContext.securityFilterChains[0].filters + + then: + filters.size() == 3 + filters[0] == applicationContext.dummyFilter + } + + void 'should add as last in existing chainmap'() { + when: + SpringSecurityUtils.clientRegisterFilter 'dummyFilter', 201 + def filters = applicationContext.securityFilterChains[0].filters + + then: + filters.size() == 3 + filters[2] == applicationContext.dummyFilter + } + + void 'authoritiesToRoles'() { + when: + def roleNames = [] + def authorities = [] + (1..10).each { i -> + String name = "role$i" + roleNames << name + authorities << new SimpleGrantedAuthority(name) + } + + def roles = SpringSecurityUtils.authoritiesToRoles(authorities) + + then: + assertSameContents roleNames, roles + } + + void 'authoritiesToRoles() when there is an authority with a null string'() { + when: + def authorities = [new SimpleGrantedAuthority('role1'), new FakeAuthority()] + SpringSecurityUtils.authoritiesToRoles(authorities) + + then: + thrown AssertionError + } + + void 'getPrincipalAuthorities() when not authenticated (no auth)'() { + expect: + !SpringSecurityUtils.principalAuthorities + } + + void 'getPrincipalAuthorities() when not authenticated (no roles)'() { + when: + SecurityTestUtils.authenticate() + + then: + !SpringSecurityUtils.principalAuthorities + } + + void 'getPrincipalAuthorities'() { + when: + def authorities = (1..10).collect { new SimpleGrantedAuthority("role$it") } + + SecurityTestUtils.authenticate null, null, authorities + + then: + authorities == SpringSecurityUtils.principalAuthorities + } + + void 'parseAuthoritiesString'() { + when: + String roleNames = 'role1,role2,role3' + def roles = SpringSecurityUtils.parseAuthoritiesString(roleNames) + + then: + 3 == roles.size() + + when: + def expected = ['role1', 'role2', 'role3'] + def actual = roles.collect { authority -> authority.authority } + + then: + assertSameContents expected, actual + } + + void 'retainAll'() { + when: + def granted = [new SimpleGrantedAuthority('role1'), + new SimpleGrantedAuthority('role2'), + new SimpleGrantedAuthority('role3')] + def required = [new SimpleGrantedAuthority('role1')] + + def expected = ['role1'] + + then: + assertSameContents expected, SpringSecurityUtils.retainAll(granted, required) + } + + void 'isAjax using parameter, false'() { + expect: + !SpringSecurityUtils.isAjax(request) + } + + void 'isAjax using parameter, true'() { + when: + request.setParameter 'ajax', 'true' + + then: + SpringSecurityUtils.isAjax request + } + + void 'isAjax using header, false'() { + expect: + !SpringSecurityUtils.isAjax(request) + } + + void 'isAjax using header, XMLHttpRequest'() { + when: + request.addHeader('X-Requested-With', 'XMLHttpRequest') + + then: + SpringSecurityUtils.isAjax request + } + + void 'isAjax using header, true'() { + when: + request.addHeader 'X-Requested-With', 'true' + + then: + !SpringSecurityUtils.isAjax(request) + } + + void 'isAjax using SavedRequest, false'() { + when: + def savedRequest = new DefaultSavedRequest(request) + request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest + + then: + !SpringSecurityUtils.isAjax(request) + } + + void 'isAjax using SavedRequest, true'() { + when: + request.addHeader 'X-Requested-With', 'true' + def savedRequest = new DefaultSavedRequest(request) + request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest + + then: + !SpringSecurityUtils.isAjax(request) + } + + void 'isAjax using SavedRequest, XMLHttpRequest'() { + when: + request.addHeader 'X-Requested-With', 'XMLHttpRequest' + def savedRequest = new DefaultSavedRequest(request) + request.session.setAttribute SpringSecurityUtils.SAVED_REQUEST, savedRequest + + then: + SpringSecurityUtils.isAjax(request) + } + + void 'ifAllGranted'() { + when: + initRoleHierarchy '' + SecurityTestUtils.authenticate(['ROLE_1', 'ROLE_2']) + + then: + SpringSecurityUtils.ifAllGranted('ROLE_1') + SpringSecurityUtils.ifAllGranted('ROLE_2') + SpringSecurityUtils.ifAllGranted('ROLE_1,ROLE_2') + !SpringSecurityUtils.ifAllGranted('ROLE_1,ROLE_2,ROLE_3') + !SpringSecurityUtils.ifAllGranted('ROLE_3') + + SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_1')]) + SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_2')]) + SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_1'), new SimpleGrantedAuthority('ROLE_2')]) + !SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_1'), new SimpleGrantedAuthority('ROLE_2'), new SimpleGrantedAuthority('ROLE_3')]) + !SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_3')]) + } + + void 'ifAllGranted using hierarchy'() { + when: + initRoleHierarchy 'ROLE_3 > ROLE_2 \n ROLE_2 > ROLE_1' + SecurityTestUtils.authenticate(['ROLE_3']) + + then: + SpringSecurityUtils.ifAllGranted('ROLE_1') + SpringSecurityUtils.ifAllGranted('ROLE_2') + SpringSecurityUtils.ifAllGranted('ROLE_1,ROLE_2') + SpringSecurityUtils.ifAllGranted('ROLE_1,ROLE_2,ROLE_3') + SpringSecurityUtils.ifAllGranted('ROLE_3') + !SpringSecurityUtils.ifAllGranted('ROLE_4') + + SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_1')]) + SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_2')]) + SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_1'), new SimpleGrantedAuthority('ROLE_2')]) + SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_1'), new SimpleGrantedAuthority('ROLE_2'), new SimpleGrantedAuthority('ROLE_3')]) + SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_3')]) + !SpringSecurityUtils.ifAllGranted([new SimpleGrantedAuthority('ROLE_4')]) + } + + void 'ifNotGranted'() { + when: + initRoleHierarchy '' + SecurityTestUtils.authenticate(['ROLE_1', 'ROLE_2']) + + then: + !SpringSecurityUtils.ifNotGranted('ROLE_1') + !SpringSecurityUtils.ifNotGranted('ROLE_2') + !SpringSecurityUtils.ifNotGranted('ROLE_1,ROLE_2') + !SpringSecurityUtils.ifNotGranted('ROLE_1,ROLE_2,ROLE_3') + SpringSecurityUtils.ifNotGranted('ROLE_3') + + !SpringSecurityUtils.ifNotGranted([new SimpleGrantedAuthority('ROLE_1')]) + !SpringSecurityUtils.ifNotGranted([new SimpleGrantedAuthority('ROLE_2')]) + !SpringSecurityUtils.ifNotGranted([new SimpleGrantedAuthority('ROLE_1'), new SimpleGrantedAuthority('ROLE_2')]) + !SpringSecurityUtils.ifNotGranted([new SimpleGrantedAuthority('ROLE_1'), new SimpleGrantedAuthority('ROLE_2'), new SimpleGrantedAuthority('ROLE_3')]) + SpringSecurityUtils.ifNotGranted([new SimpleGrantedAuthority('ROLE_3')]) + } + + void 'ifNotGranted using hierarchy'() { + when: + initRoleHierarchy 'ROLE_3 > ROLE_2 \n ROLE_2 > ROLE_1' + SecurityTestUtils.authenticate(['ROLE_3']) + + then: + !SpringSecurityUtils.ifNotGranted('ROLE_1') + !SpringSecurityUtils.ifNotGranted('ROLE_2') + !SpringSecurityUtils.ifNotGranted('ROLE_1,ROLE_2') + !SpringSecurityUtils.ifNotGranted('ROLE_1,ROLE_2,ROLE_3') + !SpringSecurityUtils.ifNotGranted('ROLE_3') + SpringSecurityUtils.ifNotGranted('ROLE_4') + } + + void 'ifAnyGranted'() { + when: + initRoleHierarchy '' + SecurityTestUtils.authenticate(['ROLE_1', 'ROLE_2']) + + then: + SpringSecurityUtils.ifAnyGranted('ROLE_1') + SpringSecurityUtils.ifAnyGranted('ROLE_2') + SpringSecurityUtils.ifAnyGranted('ROLE_1,ROLE_2') + SpringSecurityUtils.ifAnyGranted('ROLE_1,ROLE_2,ROLE_3') + !SpringSecurityUtils.ifAnyGranted('ROLE_3') + } + + void 'ifAnyGranted using hierarchy'() { + when: + initRoleHierarchy 'ROLE_3 > ROLE_2 \n ROLE_2 > ROLE_1' + SecurityTestUtils.authenticate(['ROLE_3']) + + then: + SpringSecurityUtils.ifAnyGranted('ROLE_1') + SpringSecurityUtils.ifAnyGranted('ROLE_2') + SpringSecurityUtils.ifAnyGranted('ROLE_1,ROLE_2') + SpringSecurityUtils.ifAnyGranted('ROLE_1,ROLE_2,ROLE_3') + SpringSecurityUtils.ifAnyGranted('ROLE_3') + !SpringSecurityUtils.ifAnyGranted('ROLE_4') + } + + void 'SecurityFilterPosition order should match SecurityFilters'() { + expect: + SecurityFilterPosition.SWITCH_USER_FILTER.order == SecurityFiltersMapper.SWITCH_USER_FILTER.order + } + + void 'private constructor'() { + expect: + SecurityTestUtils.testPrivateConstructor SpringSecurityUtils + } + + void 'getSecurityConfigType'() { + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = SecurityConfigType.Annotation + + then: + 'Annotation' == SpringSecurityUtils.securityConfigType + + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = SecurityConfigType.Annotation.name() + + then: + 'Annotation' == SpringSecurityUtils.securityConfigType + + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = 'Annotation' + + then: + 'Annotation' == SpringSecurityUtils.securityConfigType + + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = SecurityConfigType.InterceptUrlMap + + then: + 'InterceptUrlMap' == SpringSecurityUtils.securityConfigType + + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = SecurityConfigType.InterceptUrlMap.name() + + then: + 'InterceptUrlMap' == SpringSecurityUtils.securityConfigType + + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = 'InterceptUrlMap' + + then: + 'InterceptUrlMap' == SpringSecurityUtils.securityConfigType + + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = SecurityConfigType.Requestmap + + then: + 'Requestmap' == SpringSecurityUtils.securityConfigType + + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = SecurityConfigType.Requestmap.name() + + then: + 'Requestmap' == SpringSecurityUtils.securityConfigType + + when: + SpringSecurityUtils.resetSecurityConfig() + grailsApplication.config.grails.plugin.springsecurity.securityConfigType = 'Requestmap' + + then: + 'Requestmap' == SpringSecurityUtils.securityConfigType + } + + /** + * Check that two collections contain the same data, independent of collection class and order. + */ + private boolean assertSameContents(c1, c2) { + assert c1.size() == c2.size() + assert c1.containsAll(c2) + true + } + + private void initRoleHierarchy(String hierarchyString) { + defineBeans { + roleHierarchy(MutableRoleHierarchy) { + hierarchy = hierarchyString + } + } + } +} + +class DummyFilter extends GenericFilterBean { + void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {} +} + +class FakeAuthority implements GrantedAuthority { + String authority +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManagerSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManagerSpec.groovy new file mode 100644 index 00000000000..de459ce7ecd --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/access/vote/AuthenticatedVetoableDecisionManagerSpec.groovy @@ -0,0 +1,139 @@ +/* + * 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.access.vote + +import org.springframework.security.access.AccessDecisionVoter +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.access.ConfigAttribute +import org.springframework.security.access.SecurityConfig +import org.springframework.security.access.vote.AuthenticatedVoter +import org.springframework.security.access.vote.RoleVoter +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.authentication.RememberMeAuthenticationToken +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority + +import grails.plugin.springsecurity.AbstractUnitSpec + +/** + * Unit tests for AuthenticatedVetoableDecisionManager. + * + * @author Burt Beckwith + */ +class AuthenticatedVetoableDecisionManagerSpec extends AbstractUnitSpec { + + protected AccessDecisionVoter UnsupportedVoter = new AccessDecisionVoter() { + + @Override + boolean supports(ConfigAttribute attribute) { + return false + } + + @Override + int vote(Authentication authentication, Object object, Collection collection) { + return ACCESS_GRANTED + } + + @Override + boolean supports(Class clazz) { + return clazz.equals(Object.class) + } + } + private AuthenticatedVetoableDecisionManager manager = new AuthenticatedVetoableDecisionManager([new AuthenticatedVoter(), new RoleVoter()]) + private AuthenticatedVetoableDecisionManager unsupportedVoterManager = new AuthenticatedVetoableDecisionManager([UnsupportedVoter]) + + void 'decide with one role'() { + when: + + manager.decide createAuthentication(['ROLE_USER']), null, createDefinition(['ROLE_USER', 'ROLE_ADMIN']) + + then: + notThrown AccessDeniedException + } + + void 'decide with more than required roles'() { + when: + manager.decide createAuthentication(['ROLE_USER', 'ROLE_ADMIN']), null, createDefinition(['ROLE_USER']) + + then: + notThrown AccessDeniedException + } + + void 'decide insufficient roles'() { + when: + manager.decide createAuthentication(['ROLE_USER']), null, createDefinition(['ROLE_ADMIN']) + + then: + thrown AccessDeniedException + } + + void 'decide with IS_AUTHENTICATED_FULLY'() { + when: + manager.decide createAuthentication(['ROLE_USER']), null, createDefinition(['ROLE_USER', 'IS_AUTHENTICATED_FULLY']) + + then: + notThrown AccessDeniedException + } + + void 'decide with IS_AUTHENTICATED_FULLY and remember-me'() { + when: + def auth = new RememberMeAuthenticationToken('key', 'principal', namesToAuthorities(['ROLE_USER'])) + manager.decide auth, null, createDefinition(['ROLE_USER', 'IS_AUTHENTICATED_FULLY']) + + then: + thrown AccessDeniedException + } + + void 'decide with AnonymousAuthenticationToken'() { + when: + def auth = new AnonymousAuthenticationToken('key', 'principal', namesToAuthorities(['ROLE_USER'])) + manager.decide auth, null, createDefinition(['ROLE_USER', 'IS_AUTHENTICATED_FULLY']) + + then: + thrown AccessDeniedException + } + + void 'should succeed with supported object'() { + when: + unsupportedVoterManager.decide createAuthentication([]), new Object(), createDefinition(['ROLE_USER']) + then: + notThrown AccessDeniedException + } + + void 'should fail with unsupported object'() { + when: + unsupportedVoterManager.decide createAuthentication([]), 'not object', createDefinition(['ROLE_USER']) + then: + thrown AccessDeniedException + } + + private Authentication createAuthentication(roleNames) { + new TestingAuthenticationToken(null, null, namesToAuthorities(roleNames)) + } + + private List namesToAuthorities(roleNames) { + roleNames.collect { new SimpleGrantedAuthority(it) } + } + + private createDefinition(roleNames) { + roleNames.collect { new SecurityConfig(it) } + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/componentbased/ComponentBasedConfigBlenderSpec.groovy new file mode 100644 index 00000000000..86dbf9a92bc --- /dev/null +++ b/grails-spring-security/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/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/SecurityRequestHolderSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/SecurityRequestHolderSpec.groovy new file mode 100644 index 00000000000..b8760a1935a --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/SecurityRequestHolderSpec.groovy @@ -0,0 +1,68 @@ +/* + * 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 + +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.SecurityTestUtils + +/** + * Unit tests for SecurityRequestHolder. + * + * @author Burt Beckwith + */ +class SecurityRequestHolderSpec extends AbstractUnitSpec { + + void 'set and get'() { + expect: + !SecurityRequestHolder.request + !SecurityRequestHolder.response + + when: + SecurityRequestHolder.set request, response + + then: + request.is(SecurityRequestHolder.request) + response.is(SecurityRequestHolder.response) + } + + void 'reset'() { + expect: + !SecurityRequestHolder.request + !SecurityRequestHolder.response + + when: + SecurityRequestHolder.set request, response + + then: + request.is(SecurityRequestHolder.request) + response.is(SecurityRequestHolder.response) + + when: + SecurityRequestHolder.reset() + + then: + !SecurityRequestHolder.request + !SecurityRequestHolder.response + } + + void testPrivateConstructor() { + expect: + SecurityTestUtils.testPrivateConstructor SecurityRequestHolder + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/AjaxAwareAccessDeniedHandlerSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/AjaxAwareAccessDeniedHandlerSpec.groovy new file mode 100644 index 00000000000..6890aa4cb36 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/AjaxAwareAccessDeniedHandlerSpec.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 grails.plugin.springsecurity.web.access + +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.authentication.AuthenticationTrustResolverImpl +import org.springframework.security.authentication.RememberMeAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder as SCH +import org.springframework.security.web.PortResolverImpl +import org.springframework.security.web.savedrequest.HttpSessionRequestCache + +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.ReflectionUtils +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.web.SecurityRequestHolder + +/** + * @author Burt Beckwith + */ +class AjaxAwareAccessDeniedHandlerSpec extends AbstractUnitSpec { + + private final AjaxAwareAccessDeniedHandler handler = new AjaxAwareAccessDeniedHandler() + + void setup() { + handler.errorPage = '/fail' + handler.ajaxErrorPage = '/ajaxFail' + handler.portResolver = new PortResolverImpl() + handler.authenticationTrustResolver = new AuthenticationTrustResolverImpl() + handler.requestCache = new HttpSessionRequestCache() + ReflectionUtils.setConfigProperty 'ajaxHeader', SpringSecurityUtils.AJAX_HEADER + SecurityRequestHolder.set request, response + } + + void 'handle authenticated, remember-me, redirect'() { + when: + handler.useForward = false + + SCH.context.authentication = new RememberMeAuthenticationToken('username', 'password', null) + + then: + !request.session.getAttribute(SpringSecurityUtils.SAVED_REQUEST) + + when: + handler.handle request, response, new AccessDeniedException('fail') + + then: + request.session.getAttribute(SpringSecurityUtils.SAVED_REQUEST) + + 'http://localhost/fail' == response.redirectedUrl + !response.forwardedUrl + } + + void 'handle authenticated, remember-me, forward'() { + when: + handler.useForward = true + + SCH.context.authentication = new RememberMeAuthenticationToken('username', 'password', null) + + then: + !request.session.getAttribute(SpringSecurityUtils.SAVED_REQUEST) + + when: + handler.handle request, response, new AccessDeniedException('fail') + + then: + request.session.getAttribute(SpringSecurityUtils.SAVED_REQUEST) + + !response.redirectedUrl + '/fail' == response.forwardedUrl + } + + void 'handle authenticated, Ajax, redirect'() { + when: + handler.useForward = false + + request.addHeader SpringSecurityUtils.AJAX_HEADER, 'XMLHttpRequest' + + handler.handle request, response, new AccessDeniedException('fail') + + then: + 'http://localhost/ajaxFail' == response.redirectedUrl + !response.forwardedUrl + } + + void 'handle authenticated, Ajax, forward'() { + when: + handler.useForward = true + + request.addHeader SpringSecurityUtils.AJAX_HEADER, 'XMLHttpRequest' + + handler.handle request, response, new AccessDeniedException('fail') + + then: + '/ajaxFail' == response.forwardedUrl + !response.redirectedUrl + } + + void 'handle authenticated, not Ajax, redirect'() { + when: + handler.useForward = false + + handler.handle request, response, new AccessDeniedException('fail') + + then: + 'http://localhost/fail' == response.redirectedUrl + !response.forwardedUrl + } + + void 'handle authenticated, not Ajax, forward'() { + when: + handler.useForward = true + + handler.handle request, response, new AccessDeniedException('fail') + + then: + '/fail' == response.forwardedUrl + !response.redirectedUrl + } + + void 'respecting Grails serverURL'() { + when: + ReflectionUtils.application.config.grails.serverURL = 'http://somewhere.org' + handler.useForward = false + + handler.handle request, response, new AccessDeniedException('fail') + + then: + 'http://somewhere.org/fail' == response.redirectedUrl + !response.forwardedUrl + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/AnnotationFilterInvocationDefinitionSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/AnnotationFilterInvocationDefinitionSpec.groovy new file mode 100644 index 00000000000..6d9ae887d41 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/AnnotationFilterInvocationDefinitionSpec.groovy @@ -0,0 +1,326 @@ +/* + * 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 groovy.util.logging.Slf4j + +import spock.lang.Ignore +import spock.lang.Shared + +import org.springframework.http.HttpMethod +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.access.SecurityConfig +import org.springframework.security.web.FilterInvocation +import org.springframework.web.context.WebApplicationContext +import org.springframework.web.context.request.RequestContextHolder + +import grails.core.GrailsClass +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.InterceptedUrl +import grails.plugin.springsecurity.access.vote.ClosureConfigAttribute +import grails.plugin.springsecurity.annotation.Secured +import grails.web.CamelCaseUrlConverter +import grails.web.mapping.UrlMappingInfo +import grails.web.mapping.UrlMappingsHolder +import org.grails.core.DefaultGrailsControllerClass +import org.grails.core.artefact.ControllerArtefactHandler +import org.grails.web.mapping.DefaultUrlMappingEvaluator +import org.grails.web.mapping.DefaultUrlMappingsHolder +import org.grails.web.mime.HttpServletResponseExtension +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.util.WebUtils + +/** + * Unit tests for AnnotationFilterInvocationDefinition. + * + * @author Burt Beckwith + */ +class AnnotationFilterInvocationDefinitionSpec extends AbstractUnitSpec { + + private @Shared + HttpServletResponseExtension httpServletResponseExtension = new HttpServletResponseExtension() + private @Shared + AnnotationFilterInvocationDefinition fid = new AnnotationFilterInvocationDefinition( + httpServletResponseExtension: httpServletResponseExtension) + + void 'supports'() { + expect: + fid.supports FilterInvocation + } + + void 'lowercaseAndStripQuerystring'() { + expect: + '/foo/bar' == fid.lowercaseAndStripQuerystring('/foo/BAR') + '/foo/bar' == fid.lowercaseAndStripQuerystring('/foo/bar') + '/foo/bar' == fid.lowercaseAndStripQuerystring('/foo/BAR?x=1') + } + + void 'lowercaseAndStringQuerystring removes fragement'() { + expect: + '/foo/bar' == fid.lowercaseAndStripQuerystring('/foo/BAR#tableOfContents') + } + + void 'getAttributes for null arg'() { + when: + fid.getAttributes null + + then: + thrown AssertionError + } + + void 'getAttributes when supports is false'() { + when: + fid.getAttributes 'foo' + + then: + thrown AssertionError + } + + void 'getAttributes'() { + setup: + def chain = new MockFilterChain() + FilterInvocation filterInvocation = new FilterInvocation(request, response, chain) + + fid = new MockAnnotationFilterInvocationDefinition(httpServletResponseExtension: httpServletResponseExtension) + + def urlMappingsHolder = [matchAll: { String uri, String httpMethod, String version -> [] as UrlMappingInfo[] }] as UrlMappingsHolder + fid.initialize([], urlMappingsHolder, [] as GrailsClass[]) + WebUtils.storeGrailsWebRequest new GrailsWebRequest(request, response, servletContext) + + when: + String pattern = '/foo/**' + def configAttribute = [new SecurityConfig('ROLE_ADMIN')] + fid.storeMapping pattern, null, configAttribute + + request.requestURI = '/foo/bar' + fid.url = request.requestURI + + then: + configAttribute == fid.getAttributes(filterInvocation) + + when: + fid.rejectIfNoRule = false + request.requestURI = '/bar/foo' + fid.url = request.requestURI + + then: + !fid.getAttributes(filterInvocation) + + when: + fid.rejectIfNoRule = true + + then: + AbstractFilterInvocationDefinition.DENY == fid.getAttributes(filterInvocation) + + when: + String moreSpecificPattern = '/foo/ba*' + def moreSpecificConfigAttribute = [new SecurityConfig('ROLE_SUPERADMIN')] + fid.storeMapping moreSpecificPattern, null, moreSpecificConfigAttribute + + request.requestURI = '/foo/bar' + fid.url = request.requestURI + + then: + moreSpecificConfigAttribute == fid.getAttributes(filterInvocation) + } + + void 'determineUrl for static request'() { + when: + def request = new MockHttpServletRequest() + def response = new MockHttpServletResponse() + def filterChain = new MockFilterChain() + + request.requestURI = 'requestURI' + + fid = new MockAnnotationFilterInvocationDefinition(httpServletResponseExtension: httpServletResponseExtension) + + def urlMappingsHolder = [matchAll: { String uri, String httpMethod, String version -> [] as UrlMappingInfo[] }] as UrlMappingsHolder + fid.initialize([], urlMappingsHolder, [] as GrailsClass[]) + WebUtils.storeGrailsWebRequest new GrailsWebRequest(request, response, servletContext) + + FilterInvocation filterInvocation = new FilterInvocation(request, response, filterChain) + + then: + 'requesturi' == fid.determineUrl(filterInvocation) + } + + void 'determineUrl for dynamic request'() { + when: + def request = new MockHttpServletRequest() + def response = new MockHttpServletResponse() + def filterChain = new MockFilterChain() + + request.requestURI = 'requestURI' + + fid = new MockAnnotationFilterInvocationDefinition(url: 'FOO?x=1', application: grailsApplication, + httpServletResponseExtension: httpServletResponseExtension) + + UrlMappingInfo[] mappings = [[getControllerName: { -> 'foo' }, + getActionName : { -> 'bar' }, + configure : { GrailsWebRequest r -> }, + getRedirectInfo : { -> }] as UrlMappingInfo] + def urlMappingsHolder = [matchAll: { String uri, String httpMethod, String version -> mappings }] as UrlMappingsHolder + fid.initialize([], urlMappingsHolder, [] as GrailsClass[]) + WebUtils.storeGrailsWebRequest new GrailsWebRequest(request, response, servletContext) + + FilterInvocation filterInvocation = new FilterInvocation(request, response, filterChain) + + then: + 'foo' == fid.determineUrl(filterInvocation) + } + + @Ignore + void 'initialize'() { + when: + def mappings = { + + "/admin/user/$action?/$id?"(controller: 'adminUser') + + "/$controller/$action?/$id?" {} + + "/"(view: '/index') + + /**** Error Mappings ****/ + + "403"(controller: 'errors', action: 'accessDenied') + "404"(controller: 'errors', action: 'notFound') + "405"(controller: 'errors', action: 'notAllowed') + "500"(view: '/error') + } + + servletContext.setAttribute WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, applicationContext + + def mappingEvaluator = new DefaultUrlMappingEvaluator(servletContext) + + def urlMappingsHolder = new DefaultUrlMappingsHolder( + mappings.collect { mappingEvaluator.evaluateMappings(mappings) }.flatten()) + + List>> staticRules = [[pattern: '/js/admin/**', access: ['ROLE_ADMIN']]] + GrailsClass[] controllerClasses = [new DefaultGrailsControllerClass(ClassAnnotatedController), + new DefaultGrailsControllerClass(MethodAnnotatedController)] + controllerClasses.each { cc -> grailsApplication.addArtefact(ControllerArtefactHandler.TYPE, cc) } + + fid.roleVoter = applicationContext.getBean('roleVoter') + fid.authenticatedVoter = applicationContext.getBean('authenticatedVoter') + fid.grailsUrlConverter = new CamelCaseUrlConverter() + + fid.initialize staticRules, urlMappingsHolder, controllerClasses + + then: + 16 == fid.configAttributeMap.size() + + when: + InterceptedUrl iu + + then: + for (key in ['/classannotated', '/classannotated.*', '/classannotated/**']) { + iu = fid.getInterceptedUrl(key, null) + assert 1 == iu.configAttributes.size() + assert 'ROLE_ADMIN' == iu.configAttributes.iterator().next().attribute + assert !iu.httpMethod + } + + for (key in ['/classannotated/list', '/classannotated/list.*', '/classannotated/list/**']) { + iu = fid.getInterceptedUrl(key, null) + assert 2 == iu.configAttributes.size() + assert ['ROLE_FOO', 'ROLE_SUPERADMIN'] as Set == iu.configAttributes*.attribute as Set + assert !iu.httpMethod + } + + for (key in ['/methodannotated/list', '/methodannotated/list.*', '/methodannotated/list/**']) { + iu = fid.getInterceptedUrl(key, null) + assert 1 == iu.configAttributes.size() + assert 'ROLE_ADMIN' == iu.configAttributes.iterator().next().attribute + assert !iu.httpMethod + } + + for (key in ['/methodannotated/bar', '/methodannotated/bar.*', '/methodannotated/bar/**']) { + iu = fid.getInterceptedUrl(key, HttpMethod.PUT) + assert 1 == iu.configAttributes.size() + assert 'ROLE_ADMIN' == iu.configAttributes.iterator().next().attribute + assert HttpMethod.PUT == iu.httpMethod + } + + for (key in ['/methodannotated/foo', '/methodannotated/foo.*', '/methodannotated/foo/**']) { + iu = fid.getInterceptedUrl(key, null) + assert 1 == iu.configAttributes.size() + assert iu.configAttributes.iterator().next() instanceof ClosureConfigAttribute + assert !iu.httpMethod + } + + when: + iu = fid.getInterceptedUrl('/js/admin/**', null) + + then: + 1 == iu.configAttributes.size() + 'ROLE_ADMIN' == iu.configAttributes.iterator().next().attribute + } + + void 'findConfigAttribute'() { + when: + String pattern = '/foo/**' + def configAttributes = [new SecurityConfig('ROLE_ADMIN')] + fid.storeMapping pattern, null, configAttributes + + then: + configAttributes == fid.findConfigAttributes('/foo/bar', null) + !fid.findConfigAttributes('/bar/foo', null) + } + + void cleanup() { + RequestContextHolder.resetRequestAttributes() + } +} + +@Slf4j +class MockAnnotationFilterInvocationDefinition extends AnnotationFilterInvocationDefinition { + + String url + + protected String findGrailsUrl(UrlMappingInfo mapping) { url } +} + +@Secured('ROLE_ADMIN') +class ClassAnnotatedController { + + def index() {} + + @Secured(['ROLE_SUPERADMIN', 'ROLE_FOO']) + def list() { [results: []] } +} + +class MethodAnnotatedController { + + def index() {} + + @Secured('ROLE_ADMIN') + def list() { [results: []] } + + @Secured(closure = { + assert request + assert ctx + authentication.name == 'admin1' + }) + def foo() { [results: []] } + + @Secured(value = ['ROLE_ADMIN'], httpMethod = 'PUT') + def bar() { [results: []] } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/ChannelFilterInvocationSecurityMetadataSourceFactoryBeanSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/ChannelFilterInvocationSecurityMetadataSourceFactoryBeanSpec.groovy new file mode 100644 index 00000000000..58bc2df7e6d --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/ChannelFilterInvocationSecurityMetadataSourceFactoryBeanSpec.groovy @@ -0,0 +1,93 @@ +/* + * 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 org.springframework.security.web.access.intercept.DefaultFilterInvocationSecurityMetadataSource +import org.springframework.security.web.util.matcher.AntPathRequestMatcher + +import grails.plugin.springsecurity.AbstractUnitSpec + +/** + * @author Burt Beckwith + */ +class ChannelFilterInvocationSecurityMetadataSourceFactoryBeanSpec extends AbstractUnitSpec { + + private ChannelFilterInvocationSecurityMetadataSourceFactoryBean factory = new ChannelFilterInvocationSecurityMetadataSourceFactoryBean() + + void 'getObjectType'() { + expect: + DefaultFilterInvocationSecurityMetadataSource.is(factory.objectType) + } + + void 'isSingleton'() { + expect: + factory.singleton + } + + void 'afterPropertiesSet'() { + when: + factory.afterPropertiesSet() + + then: + thrown AssertionError + + when: + factory.afterPropertiesSet() + + then: + thrown AssertionError + + when: + factory.definition = [[pattern: '/foo1/**', access: 'secure_only']] + factory.afterPropertiesSet() + + then: + thrown AssertionError + + when: + factory.definition = [[pattern: '/foo1/**', access: 'REQUIRES_SECURE_CHANNEL']] + factory.afterPropertiesSet() + + then: + notThrown AssertionError + } + + void 'getObject'() { + when: + factory.definition = [ + [pattern: '/foo1/**', access: 'REQUIRES_SECURE_CHANNEL'], + [pattern: '/foo2/**', access: 'REQUIRES_INSECURE_CHANNEL'], + [pattern: '/foo3/**', access: 'ANY_CHANNEL'] + ] + factory.afterPropertiesSet() + + def object = factory.object + + then: + object instanceof DefaultFilterInvocationSecurityMetadataSource + + when: + def map = object.@requestMap + + then: + 'REQUIRES_SECURE_CHANNEL' == map[new AntPathRequestMatcher('/foo1/**', null, false)].attribute[0] + 'REQUIRES_INSECURE_CHANNEL' == map[new AntPathRequestMatcher('/foo2/**', null, false)].attribute[0] + 'ANY_CHANNEL' == map[new AntPathRequestMatcher('/foo3/**', null, false)].attribute[0] + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy new file mode 100644 index 00000000000..3998b13548f --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/FilterSecurityInterceptorSpec.groovy @@ -0,0 +1,69 @@ +/* + * 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/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/InterceptUrlMapFilterInvocationDefinitionSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/InterceptUrlMapFilterInvocationDefinitionSpec.groovy new file mode 100644 index 00000000000..2d1c349f8ae --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/InterceptUrlMapFilterInvocationDefinitionSpec.groovy @@ -0,0 +1,210 @@ +/* + * 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 groovy.util.logging.Slf4j + +import org.springframework.http.HttpMethod +import org.springframework.mock.web.MockFilterChain +import org.springframework.security.access.SecurityConfig +import org.springframework.security.access.vote.AuthenticatedVoter +import org.springframework.security.access.vote.RoleVoter +import org.springframework.security.web.FilterInvocation + +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.ReflectionUtils +import grails.web.mapping.UrlMappingInfo +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.util.WebUtils + +/** + * @author Burt Beckwith + */ +class InterceptUrlMapFilterInvocationDefinitionSpec extends AbstractUnitSpec { + + private InterceptUrlMapFilterInvocationDefinition fid = new InterceptUrlMapFilterInvocationDefinition() + + void 'store mapping'() { + expect: + !fid.configAttributeMap + + when: + fid.storeMapping '/foo/bar', null, ['ROLE_ADMIN'] + + then: + 1 == fid.configAttributeMap.size() + + when: + fid.storeMapping '/foo/bar', null, ['ROLE_USER'] + + then: + 1 == fid.configAttributeMap.size() + + when: + fid.storeMapping '/other/path', null, ['ROLE_SUPERUSER'] + + then: + 2 == fid.configAttributeMap.size() + } + + void 'initialize'() { + when: + ReflectionUtils.setConfigProperty('interceptUrlMap', + [[pattern: '/foo/**', access: 'ROLE_ADMIN'], + [pattern: '/bar/**', access: ['ROLE_BAR', 'ROLE_BAZ']]]) + + fid.roleVoter = applicationContext.getBean('roleVoter') + fid.authenticatedVoter = applicationContext.getBean('authenticatedVoter') + + then: + !fid.configAttributeMap + + when: + fid.initialize() + + then: + 2 == fid.configAttributeMap.size() + + when: + fid.resetConfigs() + + fid.initialize() + + then: + !fid.configAttributeMap + } + + void 'initialize with new syntax'() { + when: + ReflectionUtils.setConfigProperty('interceptUrlMap', + [[pattern: '/foo/**', access: 'ROLE_ADMIN', httpMethod: HttpMethod.POST], + [pattern: '/bar/**', access: ['ROLE_BAR', 'ROLE_BAZ']]]) + + fid.roleVoter = new RoleVoter() + fid.authenticatedVoter = new AuthenticatedVoter() + + then: + !fid.configAttributeMap + + when: + fid.initialize() + + then: + 2 == fid.configAttributeMap.size() + + when: + def interceptedUrls = ([] + fid.configAttributeMap).sort { it.pattern } + + then: + interceptedUrls[0].pattern == '/bar/**' + !interceptedUrls[0].httpMethod + null == interceptedUrls[0].https + interceptedUrls[0].configAttributes*.attribute.sort() == ['ROLE_BAR', 'ROLE_BAZ'] + + interceptedUrls[1].pattern == '/foo/**' + interceptedUrls[1].httpMethod == HttpMethod.POST + null == interceptedUrls[1].https + interceptedUrls[1].configAttributes*.attribute == ['ROLE_ADMIN'] + } + + void 'determineUrl'() { + when: + def chain = new MockFilterChain() + request.contextPath = '/context' + + request.requestURI = '/context/foo' + + then: + '/foo' == fid.determineUrl(new FilterInvocation(request, response, chain)) + + when: + request.requestURI = '/context/fOo/Bar?x=1&y=2' + + then: + '/foo/bar' == fid.determineUrl(new FilterInvocation(request, response, chain)) + } + + void 'supports'() { + expect: + fid.supports FilterInvocation + } + + void 'getAttributes'() { + when: + def chain = new MockFilterChain() + FilterInvocation filterInvocation = new FilterInvocation(request, response, chain) + + MockInterceptUrlMapFilterInvocationDefinition fid + + def initializeFid = { + fid = new MockInterceptUrlMapFilterInvocationDefinition() + fid.initialize() + WebUtils.storeGrailsWebRequest new GrailsWebRequest(request, response, servletContext) + fid + } + + def checkConfigAttributeForUrl = { config, String url -> + request.requestURI = url + fid.url = url + assert config == fid.getAttributes(filterInvocation), "Checking config for $url" + true + } + + def configAttribute = [new SecurityConfig('ROLE_ADMIN'), new SecurityConfig('ROLE_SUPERUSER')] + def moreSpecificConfigAttribute = [new SecurityConfig('ROLE_SUPERUSER')] + fid = initializeFid() + fid.storeMapping '/secure/**', null, configAttribute + fid.storeMapping '/secure/reallysecure/**', null, moreSpecificConfigAttribute + + then: + checkConfigAttributeForUrl(configAttribute, '/secure/reallysecure/list') + checkConfigAttributeForUrl(configAttribute, '/secure/list') + + when: + fid = initializeFid() + fid.storeMapping '/secure/reallysecure/**', null, moreSpecificConfigAttribute + fid.storeMapping '/secure/**', null, configAttribute + + then: + checkConfigAttributeForUrl(moreSpecificConfigAttribute, '/secure/reallysecure/list') + checkConfigAttributeForUrl(configAttribute, '/secure/list') + + when: + fid = initializeFid() + configAttribute = [new SecurityConfig('IS_AUTHENTICATED_FULLY')] + moreSpecificConfigAttribute = [new SecurityConfig('IS_AUTHENTICATED_ANONYMOUSLY')] + fid.storeMapping '/unprotected/**', null, moreSpecificConfigAttribute + fid.storeMapping '/**/*.jsp', null, configAttribute + + then: + checkConfigAttributeForUrl(moreSpecificConfigAttribute, '/unprotected/b.jsp') + checkConfigAttributeForUrl(moreSpecificConfigAttribute, '/unprotected/path') + checkConfigAttributeForUrl(moreSpecificConfigAttribute, '/unprotected/path/x.jsp') + checkConfigAttributeForUrl(configAttribute, '/b.jsp') + checkConfigAttributeForUrl(null, '/path') + } +} + +@Slf4j +class MockInterceptUrlMapFilterInvocationDefinition extends InterceptUrlMapFilterInvocationDefinition { + + String url + + protected String findGrailsUrl(UrlMappingInfo mapping) { url } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/RequestmapFilterInvocationDefinitionSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/RequestmapFilterInvocationDefinitionSpec.groovy new file mode 100644 index 00000000000..443a98698b0 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/access/intercept/RequestmapFilterInvocationDefinitionSpec.groovy @@ -0,0 +1,154 @@ +/* + * 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 groovy.util.logging.Slf4j + +import org.springframework.mock.web.MockFilterChain +import org.springframework.security.web.FilterInvocation + +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.InterceptedUrl + +/** + * Unit tests for RequestmapFilterInvocationDefinition. + * + * @author Burt Beckwith + */ +class RequestmapFilterInvocationDefinitionSpec extends AbstractUnitSpec { + + private RequestmapFilterInvocationDefinition fid = new TestRequestmapFilterInvocationDefinition() + + void 'split'() { + expect: + ['ROLE_1', 'ROLE_2', 'ROLE_3', 'ROLE_4', 'ROLE_5'] == fid.split('ROLE_1, ROLE_2,,,ROLE_3 ,ROLE_4,ROLE_5') + ['hasAnyRole("ROLE_1","ROLE_2")'] == fid.split('hasAnyRole("ROLE_1","ROLE_2")') + } + + void 'storeMapping'() { + expect: + !fid.configAttributeMap + + when: + fid.storeMapping '/foo/bar', null, ['ROLE_ADMIN'] + + then: + 1 == fid.configAttributeMap.size() + + when: + fid.storeMapping '/foo/bar', null, ['ROLE_USER'] + + then: + 1 == fid.configAttributeMap.size() + + when: + fid.storeMapping '/other/path', null, ['ROLE_SUPERUSER'] + + then: + 2 == fid.configAttributeMap.size() + } + + void 'reset'() { + when: + fid.roleVoter = applicationContext.getBean('roleVoter') + fid.authenticatedVoter = applicationContext.getBean('authenticatedVoter') + + then: + !fid.configAttributeMap + + when: + fid.reset() + + then: + 2 == fid.configAttributeMap.size() + } + + void 'initialize'() { + when: + fid.roleVoter = applicationContext.getBean('roleVoter') + fid.authenticatedVoter = applicationContext.getBean('authenticatedVoter') + + then: + !fid.configAttributeMap + + when: + fid.initialize() + + then: + 2 == fid.configAttributeMap.size() + + when: + fid.resetConfigs() + + fid.initialize() + + then: + !fid.configAttributeMap + } + + void 'determineUrl'() { + when: + def chain = new MockFilterChain() + request.contextPath = '/context' + + request.requestURI = '/context/foo' + + then: + '/foo' == fid.determineUrl(new FilterInvocation(request, response, chain)) + + when: + request.requestURI = '/context/fOo/Bar?x=1&y=2' + + then: + '/foo/bar' == fid.determineUrl(new FilterInvocation(request, response, chain)) + } + + void "test determineUrl with segmented url"() { + when: + def chain = new MockFilterChain() + request.contextPath = '/context' + request.requestURI = '/context/foo;f1=WWW/bar;s1=1;s2=2/index.html;i1=1;i2=2' + + then: + '/foo/bar/index.html' == fid.determineUrl(new FilterInvocation(request, response, chain)) + } + + void "test determineUrl context path and query params are stripped from url"() { + when: + def chain = new MockFilterChain() + request.contextPath = '/context' + request.requestURI = '/context/foo/bar/index.html?type=HelloWorld' + + then: + '/foo/bar/index.html' == fid.determineUrl(new FilterInvocation(request, response, chain)) + } + + void 'supports'() { + expect: + fid.supports FilterInvocation + } +} + +@Slf4j +class TestRequestmapFilterInvocationDefinition extends RequestmapFilterInvocationDefinition { + + protected List loadRequestmaps() { + [new InterceptedUrl('/foo/bar', ['ROLE_USER'], null), new InterceptedUrl('/admin/**', ['ROLE_ADMIN'], null)] + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy new file mode 100644 index 00000000000..80fe17ad974 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationEntryPointSpec.groovy @@ -0,0 +1,94 @@ +/* + * 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.authentication + +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. + * + * @author Burt Beckwith + */ +class AjaxAwareAuthenticationEntryPointSpec extends AbstractUnitSpec { + + private static final String loginFormUrl = '/loginFormUrl' + private static final String ajaxLoginFormUrl = '/ajaxLoginFormUrl' + + private final AjaxAwareAuthenticationEntryPoint entryPoint = new AjaxAwareAuthenticationEntryPoint(loginFormUrl) + + void setup() { + entryPoint.useForward = true + entryPoint.ajaxLoginFormUrl = ajaxLoginFormUrl + ReflectionUtils.setConfigProperty 'ajaxHeader', SpringSecurityUtils.AJAX_HEADER + SecurityRequestHolder.set request, response + } + + void 'commence() with Ajax false'() { + when: + entryPoint.commence request, response, null + + then: + loginFormUrl == response.forwardedUrl + } + + void 'commence() with Ajax true'() { + when: + request.addHeader SpringSecurityUtils.AJAX_HEADER, 'XMLHttpRequest' + + entryPoint.commence request, response, null + + then: + 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' + + then: + thrown AssertionError + + when: + entryPoint.ajaxLoginFormUrl = '/foo' + + then: + notThrown AssertionError + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationFailureHandlerSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationFailureHandlerSpec.groovy new file mode 100644 index 00000000000..8817a178d7f --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationFailureHandlerSpec.groovy @@ -0,0 +1,96 @@ +/* + * 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.authentication + +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.web.RedirectStrategy + +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.web.SecurityRequestHolder + +/** + * @author Burt Beckwith + */ +class AjaxAwareAuthenticationFailureHandlerSpec extends AbstractUnitSpec { + + private final AjaxAwareAuthenticationFailureHandler handler = new AjaxAwareAuthenticationFailureHandler() + + void setup() { + SecurityRequestHolder.set request, response + } + + void 'onAuthenticationFailure not Ajax'() { + when: + String defaultFailureUrl = '/defaultFailureUrl' + handler.defaultFailureUrl = defaultFailureUrl + handler.ajaxAuthenticationFailureUrl = '/ajaxAuthenticationFailureUrl' + + boolean redirectCalled = false + def sendRedirect = { req, res, url -> + redirectCalled = true + assert defaultFailureUrl == url + } + handler.redirectStrategy = [sendRedirect: sendRedirect] as RedirectStrategy + + SpringSecurityUtils.securityConfig = [ajaxHeader: 'ajaxHeader'] as ConfigObject + + handler.onAuthenticationFailure request, response, new BadCredentialsException('fail') + + then: + redirectCalled + } + + void 'onAuthenticationFailure Ajax'() { + when: + String ajaxAuthenticationFailureUrl = '/ajaxAuthenticationFailureUrl' + handler.defaultFailureUrl = '/defaultFailureUrl' + handler.ajaxAuthenticationFailureUrl = ajaxAuthenticationFailureUrl + + boolean redirectCalled = false + def sendRedirect = { req, res, url -> + redirectCalled = true + assert ajaxAuthenticationFailureUrl == url + } + handler.redirectStrategy = [sendRedirect: sendRedirect] as RedirectStrategy + + SpringSecurityUtils.securityConfig = [ajaxHeader: 'ajaxHeader'] as ConfigObject + + request.addHeader 'ajaxHeader', 'XMLHttpRequest' + handler.onAuthenticationFailure request, response, new BadCredentialsException('fail') + + then: + redirectCalled + } + + void 'afterPropertiesSet'() { + when: + handler.afterPropertiesSet() + + then: + thrown AssertionError + + when: + handler.ajaxAuthenticationFailureUrl = 'url' + handler.afterPropertiesSet() + + then: + notThrown AssertionError + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationSuccessHandlerSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationSuccessHandlerSpec.groovy new file mode 100644 index 00000000000..febb3c1809a --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/AjaxAwareAuthenticationSuccessHandlerSpec.groovy @@ -0,0 +1,90 @@ +/* + * 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.authentication + +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.web.RedirectStrategy +import org.springframework.security.web.savedrequest.NullRequestCache +import org.springframework.security.web.savedrequest.RequestCache +import org.springframework.security.web.savedrequest.SavedRequest + +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.web.SecurityRequestHolder + +/** + * @author Burt Beckwith + */ +class AjaxAwareAuthenticationSuccessHandlerSpec extends AbstractUnitSpec { + + private static final String AJAX_SUCCESS_URL = '/ajaxSuccessUrl' + private static final String DEFAULT_TARGET_URL = '/defaultTargetUrl' + + private final AjaxAwareAuthenticationSuccessHandler handler = new AjaxAwareAuthenticationSuccessHandler() + + void setup() { + handler.defaultTargetUrl = DEFAULT_TARGET_URL + handler.ajaxSuccessUrl = AJAX_SUCCESS_URL + + SpringSecurityUtils.securityConfig = [ajaxHeader: 'ajaxHeader'] as ConfigObject + SecurityRequestHolder.set request, response + } + + void 'determineTargetUrl with Ajax'() { + when: + handler.requestCache = new NullRequestCache() + handler.alwaysUseDefaultTargetUrl = true + + request.addHeader 'ajaxHeader', 'XMLHttpRequest' + + handler.onAuthenticationSuccess(request, response, new TestingAuthenticationToken('username', 'password')) + + then: + AJAX_SUCCESS_URL == response.redirectedUrl + } + + void 'determineTargetUrl without Ajax'() { + when: + handler.requestCache = new NullRequestCache() + handler.onAuthenticationSuccess(request, response, new TestingAuthenticationToken('username', 'password')) + + then: + DEFAULT_TARGET_URL == response.redirectedUrl + } + + void 'onAuthenticationSuccess'() { + when: + Authentication authentication = new TestingAuthenticationToken('username', 'password') + + String expectedRedirect = 'expectedRedirect' + SavedRequest savedRequest = [getRedirectUrl: { -> expectedRedirect }] as SavedRequest + boolean removeRequestCalled = false + handler.requestCache = [removeRequest: { req, res -> removeRequestCalled = true }, + getRequest : { req, res -> savedRequest }] as RequestCache + String redirectUrl + handler.redirectStrategy = [sendRedirect: { req, res, url -> redirectUrl = url }] as RedirectStrategy + + handler.onAuthenticationSuccess(request, response, authentication) + + then: + removeRequestCalled + expectedRedirect == redirectUrl + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/SecurityRequestHolderFilterSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/SecurityRequestHolderFilterSpec.groovy new file mode 100644 index 00000000000..19fb3ff4d23 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/SecurityRequestHolderFilterSpec.groovy @@ -0,0 +1,54 @@ +/* + * 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.authentication + +import jakarta.servlet.FilterChain + +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.web.SecurityRequestHolder +import grails.plugin.springsecurity.web.SecurityRequestHolderFilter + +/** + * @author Burt Beckwith + */ +class SecurityRequestHolderFilterSpec extends AbstractUnitSpec { + + private SecurityRequestHolderFilter filter = new SecurityRequestHolderFilter() + + void 'doFilter'() { + expect: + !SecurityRequestHolder.request + !SecurityRequestHolder.response + + when: + boolean chainCalled = false + def chain = [doFilter: { req, res -> + assert SecurityRequestHolder.request + assert SecurityRequestHolder.response + chainCalled = true + }] as FilterChain + + filter.doFilter request, response, chain + + then: + chainCalled + !SecurityRequestHolder.request + !SecurityRequestHolder.response + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/logout/MutableLogoutFilterSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/logout/MutableLogoutFilterSpec.groovy new file mode 100644 index 00000000000..7770f709174 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/authentication/logout/MutableLogoutFilterSpec.groovy @@ -0,0 +1,90 @@ +/* + * 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.authentication.logout + +import jakarta.servlet.FilterChain + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.web.authentication.logout.LogoutHandler +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler + +import grails.plugin.springsecurity.AbstractUnitSpec +import grails.plugin.springsecurity.SecurityTestUtils + +/** + * Unit tests for MutableLogoutFilter. + * + * @author Burt Beckwith + */ +class MutableLogoutFilterSpec extends AbstractUnitSpec { + + private static final String filterProcessesUrl = '/logoff' + private static final String afterLogoutUrl = '/loggedout' + + private final logoutSuccessHandler = new SimpleUrlLogoutSuccessHandler(defaultTargetUrl: afterLogoutUrl) + private final handlers = [] + private final filter = new MutableLogoutFilter(logoutSuccessHandler) + + private int logoutCount + + void setup() { + 5.times { + handlers << ([logout: { req, res, auth -> logoutCount++ }] as LogoutHandler) + } + filter.handlers = handlers + filter.filterProcessesUrl = filterProcessesUrl + } + + void 'doFilter'() { + given: + SecurityTestUtils.authenticate() + + def request1 = new MockHttpServletRequest(method: 'GET', requestURI: '/foo/bar') + def response1 = new MockHttpServletResponse() + def request2 = new MockHttpServletRequest(method: 'GET', requestURI: filterProcessesUrl) + def response2 = new MockHttpServletResponse() + + boolean chain1Called = false + boolean chain2Called = false + def chain1 = [doFilter: { req, res -> + chain1Called = true + }] as FilterChain + def chain2 = [doFilter: { req, res -> + chain2Called = true + }] as FilterChain + + when: + // not a logout url, so chain.doFilter() is called + filter.doFilter request1, response1, chain1 + + then: + !response1.redirectedUrl + + when: + filter.doFilter request2, response2, chain2 + + then: + response2.redirectedUrl + + chain1Called + !chain2Called + 5 == logoutCount + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/filter/HttpMethodOverrideDetectorSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/filter/HttpMethodOverrideDetectorSpec.groovy new file mode 100644 index 00000000000..4b1c2dd6d4c --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/filter/HttpMethodOverrideDetectorSpec.groovy @@ -0,0 +1,85 @@ +/* + * 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.filter + +import org.springframework.http.HttpMethod + +import grails.plugin.springsecurity.AbstractUnitSpec + +class HttpMethodOverrideDetectorSpec extends AbstractUnitSpec { + + public static final String METHOD_PARAM_NAME = 'MeThOd' + private final HttpMethodOverrideDetector detector = new HttpMethodOverrideDetector() + + void "verify setMethodParam with value"() { + when: + detector.setMethodParam(METHOD_PARAM_NAME) + + then: + detector.methodParam == METHOD_PARAM_NAME + } + + void "verify setMethodParam with null value"() { + when: + detector.setMethodParam(null) + + then: + thrown IllegalArgumentException + } + + void "getHttpMethodOverride returns correct http method if http method is set on request params"() { + given: "a request with a method parameter" + request.addParameter(detector.DEFAULT_METHOD_PARAM, paramValue) + + when: "getHttpMethodOverride is called" + String httpMethodOverride = detector.getHttpMethodOverride(request) + + then: "it returns the method from the request parameter" + httpMethodOverride == paramValue + + where: + paramValue << [HttpMethod.PATCH.name(), HttpMethod.PUT.name()] + } + + void "getHttpMethodOverride returns correct http method if http method is set in request header"() { + given: "the method override header is set to 'TRACE'" + request.addHeader detector.HEADER_X_HTTP_METHOD_OVERRIDE, HttpMethod.TRACE.name() + + when: "getHttpMethodOverride is called" + String httpMethodOverride = detector.getHttpMethodOverride(request) + + then: "the method override included in the request header is returned" + httpMethodOverride == HttpMethod.TRACE.name() + } + + void "getHttpMethodOverride returns correct http method when http method is set in both request header and request params"() { + given: "the method override header is set to 'TRACE'" + request.addHeader detector.HEADER_X_HTTP_METHOD_OVERRIDE, HttpMethod.TRACE.name() + + and: "the method is set to DELETE in params" + request.addParameter(detector.DEFAULT_METHOD_PARAM, HttpMethod.DELETE.name()) + + when: "getHttpMethodOverride is called" + String httpMethodOverride = detector.getHttpMethodOverride(request) + + then: "the method override included in the request params is returned" + httpMethodOverride == HttpMethod.DELETE.name() + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilterSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilterSpec.groovy new file mode 100644 index 00000000000..57bd1513736 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/web/filter/IpAddressFilterSpec.groovy @@ -0,0 +1,192 @@ +/* + * 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.filter + +import jakarta.servlet.FilterChain + +import grails.plugin.springsecurity.AbstractUnitSpec + +/** + * Unit tests for IpAddressFilter. + * + * @author Burt Beckwith + */ +class IpAddressFilterSpec extends AbstractUnitSpec { + + private final IpAddressFilter filter = new IpAddressFilter() + + void 'afterPropertiesSet'() { + when: + filter.afterPropertiesSet() + + then: + thrown AssertionError + + when: + filter.ipRestrictions = [ + [pattern: '/foo/**', access: '127.0.0.1'], + [pattern: '/bar/**', access: '10.0.0.0/8'], + [pattern: '/wahoo/**', access: '10.10.200.63'] + ] + + filter.afterPropertiesSet() + + then: + notThrown AssertionError + } + + void 'access can be String or Collection/Array of String'() { + given: + filter.ipRestrictions = [ + [pattern: '/foo/**', access: '127.0.0.1'], + [pattern: '/bar/**', access: '10.0.0.0/8'], + [pattern: '/wahoo/**', access: ['10.10.200.63', '10.10.200.64']] + ] + + when: + filter.afterPropertiesSet() + + then: + filter.restrictions.size() == 3 + + and: + filter.restrictions[0].pattern == '/foo/**' + filter.restrictions[0].configAttributes.size() == 1 + filter.restrictions[0].configAttributes[0].attribute == '127.0.0.1' + + and: + filter.restrictions[1].pattern == '/bar/**' + filter.restrictions[1].configAttributes.size() == 1 + filter.restrictions[1].configAttributes[0].attribute == '10.0.0.0/8' + + and: + filter.restrictions[2].pattern == '/wahoo/**' + filter.restrictions[2].configAttributes.size() == 2 + filter.restrictions[2].configAttributes[0].attribute == '10.10.200.63' + filter.restrictions[2].configAttributes[1].attribute == '10.10.200.64' + } + + void 'doFilter HTTP allowed'() { + when: + filter.ipRestrictions = [ + [pattern: '/foo/**', access: '127.0.0.1'], + [pattern: '/bar/**', access: '10.0.0.0/8'], + [pattern: '/wahoo/**', access: '10.10.200.63'], + [pattern: '/masked/**', access: '192.168.1.0/24'] + ] + + int chainCount = 0 + def chain = [doFilter: { req, res -> chainCount++ }] as FilterChain + + request.remoteAddr = '127.0.0.1' + request.requestURI = '/foo/bar?x=123' + filter.doFilter request, response, chain + + request.remoteAddr = '10.10.111.222' + request.requestURI = '/bar/foo?x=123' + filter.doFilter request, response, chain + + request.remoteAddr = '10.10.200.63' + request.requestURI = '/wahoo/list' + filter.doFilter request, response, chain + + request.remoteAddr = '63.161.169.137' + request.requestURI = '/my/united/states/of/whatever' + filter.doFilter request, response, chain + + request.remoteAddr = '192.168.1.123' + request.requestURI = '/masked/shouldsucceed' + filter.doFilter request, response, chain + + then: + 5 == chainCount + } + + void 'doFilter HTTP denied'() { + when: + filter.ipRestrictions = [ + [pattern: '/foo/**', access: '127.0.0.1'], + [pattern: '/bar/**', access: '10.0.0.0/8'], + [pattern: '/wahoo/**', access: '10.10.200.63'], + [pattern: '/masked/**', access: '192.168.1.0/24'] + ] + + int chainCount = 0 + def chain = [doFilter: { req, res -> chainCount++ }] as FilterChain + + request.remoteAddr = '63.161.169.137' + + request.requestURI = '/foo/bar?x=123' + + filter.doFilter request, response, chain + + then: + 404 == response.status + + when: + request.requestURI = '/bar/foo?x=123' + response.reset() + filter.doFilter request, response, chain + + then: + 404 == response.status + + when: + request.requestURI = '/wahoo/list' + response.reset() + filter.doFilter request, response, chain + + then: + 404 == response.status + + when: + request.requestURI = '/masked/shouldfail' + response.reset() + filter.doFilter request, response, chain + + then: + 404 == response.status + + 0 == chainCount + } + + void 'doFilter mix IPv6 and IPv4'() { + when: + filter.ipRestrictions = [ + [pattern: '/foo/**', access: '127.0.0.1'], + [pattern: '/bar/**', access: '10.0.0.0/8'], + [pattern: '/wahoo/**', access: '10.10.200.63'], + [pattern: '/masked/**', access: '192.168.1.0/24'] + ] + + int chainCount = 0 + def chain = [doFilter: { req, res -> chainCount++ }] as FilterChain + + request.remoteAddr = 'fa:db8:85a3::8a2e:370:7334' + + request.requestURI = '/masked/bar?x=123' + + filter.doFilter request, response, chain + + then: + 404 == response.status + + 0 == chainCount + } +} diff --git a/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy b/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy new file mode 100644 index 00000000000..0c790d268b5 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/intercept/aopalliance/MethodSecurityInterceptorSpec.groovy @@ -0,0 +1,142 @@ +/* + * 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/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy b/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy new file mode 100644 index 00000000000..d34877e5d82 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/method/AbstractFallbackMethodSecurityMetadataSourceSpec.groovy @@ -0,0 +1,59 @@ +/* + * 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/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy b/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy new file mode 100644 index 00000000000..13a3f452457 --- /dev/null +++ b/grails-spring-security/plugin/src/test/groovy/org/springframework/security/access/prepost/MethodSecurityExpressionCompatibilitySpec.groovy @@ -0,0 +1,152 @@ +/* + * 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 getReports() { + [] + } + } +} diff --git a/grails-spring-security/plugin/src/testFixtures/groovy/grails/plugin/springsecurity/SecurityTestUtils.groovy b/grails-spring-security/plugin/src/testFixtures/groovy/grails/plugin/springsecurity/SecurityTestUtils.groovy new file mode 100644 index 00000000000..461725b73a3 --- /dev/null +++ b/grails-spring-security/plugin/src/testFixtures/groovy/grails/plugin/springsecurity/SecurityTestUtils.groovy @@ -0,0 +1,79 @@ +/* + * 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 java.lang.reflect.Modifier + +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder as SCH + +/** + * Utility methods for security unit tests. + * + * @author Burt Beckwith + */ +class SecurityTestUtils { + + /** + * Register a currently authenticated user. + * @return the authentication + */ + static Authentication authenticate() { + authenticate null, null, null + } + + /** + * Register a currently authenticated user. + * + * @param principal the principal + * @param credentials the password + * @param authorities the roles + * @return the authentication + */ + static Authentication authenticate(principal, credentials, List authorities) { + Authentication authentication = authorities != null ? new TestingAuthenticationToken(principal, credentials, authorities) : new TestingAuthenticationToken(principal, credentials) + authentication.authenticated = true + SCH.context.authentication = authentication + authentication + } + + static Authentication authenticate(roleNames) { + authenticate null, null, roleNames.collect { new SimpleGrantedAuthority(it) } + } + + /** + * De-register the currently authenticated user. + */ + static void logout() { + SCH.clearContext() + } + + static boolean testPrivateConstructor(Class clazz) { + assert 1 == clazz.declaredConstructors.length + def constructor = clazz.getDeclaredConstructor() + assert Modifier.isPrivate(constructor.modifiers) + assert !constructor.accessible + constructor.accessible = true + constructor.newInstance() + true + } +} diff --git a/grails-spring-security/plugin/src/testFixtures/groovy/org/springframework/security/config/http/SecurityFiltersMapper.groovy b/grails-spring-security/plugin/src/testFixtures/groovy/org/springframework/security/config/http/SecurityFiltersMapper.groovy new file mode 100644 index 00000000000..afc004f7a0d --- /dev/null +++ b/grails-spring-security/plugin/src/testFixtures/groovy/org/springframework/security/config/http/SecurityFiltersMapper.groovy @@ -0,0 +1,25 @@ +/* + * 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.config.http + +class SecurityFiltersMapper { + + static final SWITCH_USER_FILTER = SecurityFilters.SWITCH_USER_FILTER +} diff --git a/grails-spring-security/rest/core/build.gradle b/grails-spring-security/rest/core/build.gradle new file mode 100644 index 00000000000..8cb2518408c --- /dev/null +++ b/grails-spring-security/rest/core/build.gradle @@ -0,0 +1,133 @@ +/* + * 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. + */ + +plugins { + id 'groovy' + id 'java-library' + id 'project-report' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.gradle.grails-code-style' + id 'org.apache.grails.gradle.grails-plugin' +} + +version = projectVersion +group = 'org.apache.grails' + +dependencies { + + implementation platform(project(':grails-bom')) + + api project(':grails-spring-security'), { + // api: NullAuthenticationEventPublisher, SecurityEventListener + // impl: BeanTypeResolver, @Secured(runtime), SecurityFilterPosition, SpringSecurityUtils + } + api "com.nimbusds:nimbus-jose-jwt", { + // api: EncryptionMethod, JOSEException, JWEAlgorithm, JWSAlgorithm, JWSSigner, JWT, JWTClaimsSet + // impl: EncryptedJWT, EncryptionMethod, JWEHeader, JWSHeader, JWTParser, MACSigner, MACVerifier, PlainJWT, RSADecrypter, RSAEncrypter, SignedJWT + } + api project(':grails-web-url-mappings'), { + // api: LinkGenerator + } + api "org.pac4j:pac4j-core", { + // api: CallContext, CommonProfile, IndirectClient, UserProfile + // impl: Credentials, RedirectionAction, WebContext + } + api 'org.springframework:spring-beans', { + // api: InitializingBean + // impl: BeanWrapperImpl + } + api 'org.springframework:spring-context', { + // api: ApplicationContextAware, ApplicationEvent, ApplicationEventPublisher, ApplicationListener + } + api 'org.springframework:spring-web', { + // api: GenericFilterBean + // impl: HttpStatus, MediaType + } + api 'org.springframework.security:spring-security-core', { + // api: AbstractAuthenticationToken, AccessDeniedException, Authentication, AuthenticationDetailsSource, AuthenticationEventPublisher, + // AuthenticationException, AuthenticationFailureHandler, AuthenticationManager, AuthenticationProvider, + // AuthenticationSuccessHandler, DefaultAuthenticationEventPublisher, GrantedAuthority, User, UserDetails, + // UserDetailsChecker, UserDetailsService, UsernameNotFoundException, UsernamePasswordAuthenticationToken + // impl: SecurityContextHolder, SimpleGrantedAuthority + } + api 'org.springframework.security:spring-security-crypto', { + // api: PasswordEncoder + // impl: Argon2PasswordEncoder, BCryptPasswordEncoder, DelegatingPasswordEncoder, LdapShaPasswordEncoder, + // Md4PasswordEncoder, MessageDigestPasswordEncoder, NoOpPasswordEncoder, Pbkdf2PasswordEncoder, + // StandardPasswordEncoder, SCryptPasswordEncoder + } + api 'org.springframework.security:spring-security-web', { + // api: AccessDeniedHandlerImpl, AuthenticationEntryPoint, RequestMatcher + // impl: ExceptionTranslationFilter, Http403ForbiddenEntryPoint, NullRequestCache + } + + implementation "org.apache.commons:commons-lang3", { + // impl: RandomStringUtils + } + implementation "com.google.guava:guava", { + // impl: CacheBuilder, LoadingCache + } + implementation 'commons-codec:commons-codec', { + // impl: Base64 + } + implementation project(':grails-codecs'), { + // impl: URLCodec + } + implementation project(':grails-converters'), { + // impl: JSON + } + implementation "org.pac4j:pac4j-jakartaee", { + // impl: JEEContext, JEESessionStore + } + implementation 'org.springframework:spring-core', { + // impl: Assert + } + + runtimeOnly project(':grails-services'), { + // Project has services + } + + compileOnly 'org.apache.groovy:groovy' // Provided as this is a Grails plugin + compileOnly project(':grails-core') // Provided as this is a Grails plugin + compileOnly project(':grails-controllers'), { + // ControllerTraitInjector + } + compileOnly 'jakarta.servlet:jakarta.servlet-api', { // Provided by Tomcat + // api: FilterChain, HttpServletResponse, HttpServletRequest, ServletException, ServletRequest, ServletResponse + } + + testImplementation "org.pac4j:pac4j-oauth", { + // impl: CasOAuthWrapperClient + } + testImplementation project(':grails-testing-support-datamapping') + testImplementation project(':grails-testing-support-web') + testImplementation 'org.spockframework:spock-core' + + testRuntimeOnly 'net.bytebuddy:byte-buddy' +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/spring-security-test-config.gradle') +} diff --git a/grails-spring-security/rest/core/grails-app/conf/application.yml b/grails-spring-security/rest/core/grails-app/conf/application.yml new file mode 100644 index 00000000000..077e58ce8ad --- /dev/null +++ b/grails-spring-security/rest/core/grails-app/conf/application.yml @@ -0,0 +1,37 @@ +# 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. + +--- +grails: + profile: web-plugin + codegen: + defaultPackage: grails.plugin.springsecurity.rest +info: + app: + name: '@info.app.name@' + version: '@info.app.version@' + grailsVersion: '@info.app.grailsVersion@' +dataSource: + pooled: true + driverClassName: "org.h2.Driver" + username: sa + password: + dbCreate: update + url: jdbc:h2:mem:testDb +hibernate: + cache: + use_second_level_cache: false + use_query_cache: false + region.factory_class: 'org.hibernate.cache.ehcache.EhCacheRegionFactory' \ No newline at end of file diff --git a/grails-spring-security/rest/core/grails-app/conf/logback.groovy b/grails-spring-security/rest/core/grails-app/conf/logback.groovy new file mode 100644 index 00000000000..8d4114e0633 --- /dev/null +++ b/grails-spring-security/rest/core/grails-app/conf/logback.groovy @@ -0,0 +1,32 @@ +/* + * 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. + */ +// See http://logback.qos.ch/manual/groovy.html for details on configuration +appender('STDOUT', ConsoleAppender) { + encoder(PatternLayoutEncoder) { + pattern = "%level %logger - %msg%n" + } +} + +root(ERROR, ['STDOUT']) + +logger('org.codehaus.groovy.grails', ERROR, ['STDOUT']) +logger('org.springframework', ERROR, ['STDOUT']) + +logger("grails.plugin.springsecurity.rest", DEBUG, ['STDOUT'], false) +logger("org.springframework.security", DEBUG, ['STDOUT'], false) \ No newline at end of file diff --git a/grails-spring-security/rest/core/grails-app/controllers/grails/plugin/springsecurity/rest/RestOauthController.groovy b/grails-spring-security/rest/core/grails-app/controllers/grails/plugin/springsecurity/rest/RestOauthController.groovy new file mode 100644 index 00000000000..fd7c8b5dbac --- /dev/null +++ b/grails-spring-security/rest/core/grails-app/controllers/grails/plugin/springsecurity/rest/RestOauthController.groovy @@ -0,0 +1,193 @@ +/* + * 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.rest + +import java.nio.charset.StandardCharsets + +import groovy.util.logging.Slf4j + +import com.nimbusds.jwt.JWT +import org.pac4j.core.client.IndirectClient +import org.pac4j.core.context.CallContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.exception.http.FoundAction +import org.pac4j.core.exception.http.RedirectionAction +import org.pac4j.jee.context.JEEContext +import org.pac4j.jee.context.session.JEESessionStoreFactory + +import org.springframework.http.HttpStatus +import org.springframework.security.core.userdetails.User + +import grails.core.GrailsApplication +import grails.plugin.springsecurity.annotation.Secured +import grails.plugin.springsecurity.rest.authentication.RestAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.error.CallbackErrorHandler +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.rendering.AccessTokenJsonRenderer +import grails.plugin.springsecurity.rest.token.storage.TokenStorageService +import org.grails.plugins.codecs.URLCodec + +@Slf4j +@Secured(['permitAll']) +class RestOauthController { + + static allowedMethods = [accessToken: 'POST'] + private static final SessionStore SESSION_STORE = JEESessionStoreFactory.INSTANCE.newSessionStore(null) + final String CALLBACK_ATTR = 'spring-security-rest-callback' + + CallbackErrorHandler callbackErrorHandler + RestOauthService restOauthService + GrailsApplication grailsApplication + + JwtService jwtService + TokenStorageService tokenStorageService + def tokenGenerator + AccessTokenJsonRenderer accessTokenJsonRenderer + RestAuthenticationEventPublisher authenticationEventPublisher + /** + * Starts the OAuth authentication flow, redirecting to the provider's Login URL. An optional callback parameter + * allows the frontend application to define the frontend callback URL on demand. + */ + def authenticate(String provider, String callback) { + IndirectClient client = restOauthService.getClient(provider) + + if (callback) { + try { + if (Base64.isBase64(callback.getBytes())) { + callback = new String(callback.decodeBase64(), StandardCharsets.UTF_8) + } + log.debug "Trying to store in the HTTP session a user specified callback URL: ${callback}" + session[CALLBACK_ATTR] = new URL(callback).toString() + } catch (MalformedURLException mue) { + log.warn 'The URL is malformed, is it base64 encoded? Not storing it.' + } + } + + CallContext callContext = new CallContext(new JEEContext(request, response), SESSION_STORE) + RedirectionAction redirectAction = client.getRedirectionAction(callContext).get() + if (redirectAction instanceof FoundAction) { + log.debug "Redirecting to ${redirectAction.location}" + redirect url: redirectAction.location + } else { + response.status = redirectAction.code + } + } + + /** + * Handles the OAuth provider callback. It uses {@link RestOauthService} to generate and store a token for that user, + * and finally redirects to the configured frontend callback URL, where the token is in the URL. That way, the + * frontend application can store the REST API token locally for subsequent API calls. + */ + def callback(String provider) { + CallContext context = new CallContext(new JEEContext(request, response), SESSION_STORE) + def frontendCallbackUrl + if (session[CALLBACK_ATTR]) { + log.debug 'Found callback URL in the HTTP session' + frontendCallbackUrl = session[CALLBACK_ATTR] + } else { + log.debug 'Found callback URL in the configuration file' + frontendCallbackUrl = grailsApplication.config['grails.plugin.springsecurity.rest.oauth.frontendCallbackUrl'] + } + + try { + String tokenValue = restOauthService.storeAuthentication(provider, context) + frontendCallbackUrl = getCallbackUrl(frontendCallbackUrl, tokenValue) + + } catch (Exception e) { + def errorParams = new StringBuilder() + + Map params = callbackErrorHandler.convert(e) + + URLCodec urlCodec = new URLCodec() + params.each { key, value -> + errorParams << "&${key}=${urlCodec.encoder.encode(value)}" + } + + frontendCallbackUrl = getCallbackUrl(frontendCallbackUrl, errorParams.toString()) + } + + log.debug "Redirecting to ${frontendCallbackUrl}" + redirect url: frontendCallbackUrl + } + + private String getCallbackUrl(baseUrl, String queryStringSuffix) { + session[CALLBACK_ATTR] = null + baseUrl instanceof Closure ? baseUrl(queryStringSuffix) : baseUrl + queryStringSuffix + } + + /** + * Generates a new access token given the refresh token passed + */ + def accessToken() { + String grantType = params['grant_type'] + if (!grantType || grantType != 'refresh_token') { + render status: HttpStatus.BAD_REQUEST, text: 'Invalid grant_type' + return + } + + String refreshToken = params['refresh_token'] + log.debug "Trying to generate an access token for the refresh token: ${refreshToken}" + if (!refreshToken) { + log.debug 'Refresh token is missing. Replying with bad request' + render status: HttpStatus.BAD_REQUEST, text: 'Refresh token is required' + return + } + + // only JWT tokens can be refreshed + if (!AbstractJwtTokenGenerator.isAssignableFrom(tokenGenerator.getClass())) { + log.debug('Token type does not support refresh tokens') + render status: HttpStatus.FORBIDDEN + return + } + + try { + JWT jwt = jwtService.parse(refreshToken) + if (!jwt || !jwt.JWTClaimsSet.getBooleanClaim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM)) { + log.debug("Token ${refreshToken} is not a refresh token") + render status: HttpStatus.FORBIDDEN + return + } + } + catch (e) { + log.debug("Invalid refresh token: ${refreshToken}", e) + render status: HttpStatus.FORBIDDEN + return + } + + try { + def user = tokenStorageService.loadUserByToken(refreshToken) + User principal = user ? user as User : null + log.debug "Principal found for refresh token: ${principal}" + + AccessToken accessToken = (tokenGenerator as AbstractJwtTokenGenerator).generateAccessToken(principal, false) + accessToken.refreshToken = refreshToken + + tokenStorageService.storeToken(accessToken) + authenticationEventPublisher.publishTokenCreation(accessToken) + + response.addHeader 'Cache-Control', 'no-store' + response.addHeader 'Pragma', 'no-cache' + render contentType: 'application/json', encoding: 'UTF-8', text: accessTokenJsonRenderer.generateJson(accessToken) + } catch (e) { + log.debug('Could not load by refresh token', e) + render status: HttpStatus.FORBIDDEN + } + } +} diff --git a/grails-spring-security/rest/core/grails-app/controllers/grails/plugin/springsecurity/rest/RestOauthUrlMappings.groovy b/grails-spring-security/rest/core/grails-app/controllers/grails/plugin/springsecurity/rest/RestOauthUrlMappings.groovy new file mode 100644 index 00000000000..4744aa98cef --- /dev/null +++ b/grails-spring-security/rest/core/grails-app/controllers/grails/plugin/springsecurity/rest/RestOauthUrlMappings.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 grails.plugin.springsecurity.rest + +class RestOauthUrlMappings { + + static mappings = { + + name oauth: "/oauth/${action}/${provider}"(controller: 'restOauth') + + '/oauth/access_token'(controller: 'restOauth', action: 'accessToken') + + } +} diff --git a/grails-spring-security/rest/core/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy b/grails-spring-security/rest/core/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy new file mode 100644 index 00000000000..2c7b2c5be4c --- /dev/null +++ b/grails-spring-security/rest/core/grails-app/services/grails/plugin/springsecurity/rest/JwtService.groovy @@ -0,0 +1,125 @@ +/* + * 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.rest + +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +import groovy.util.logging.Slf4j + +import com.nimbusds.jose.JOSEException +import com.nimbusds.jose.crypto.MACVerifier +import com.nimbusds.jose.crypto.RSADecrypter +import com.nimbusds.jwt.EncryptedJWT +import com.nimbusds.jwt.JWT +import com.nimbusds.jwt.JWTParser +import com.nimbusds.jwt.PlainJWT +import com.nimbusds.jwt.SignedJWT + +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.generation.jwt.RSAKeyProvider +import grails.util.Holders + +/** + * Helper to perform actions with JWT tokens + */ +@Slf4j +class JwtService { + + String jwtSecret + RSAKeyProvider keyProvider + + /** + * Parses and verifies (for signed tokens) or decrypts (for encrypted tokens) the given token + * + * @param tokenValue a JWT token + * @return a {@link JWT} object + * @throws JOSEException when verification/decryption fails + */ + JWT parse(String tokenValue) { + JWT jwt = JWTParser.parse(tokenValue) + + if (jwt instanceof SignedJWT) { + log.debug 'Parsed an HMAC signed JWT' + + SignedJWT signedJwt = jwt as SignedJWT + if (!signedJwt.verify(new MACVerifier(jwtSecret))) { + throw new JOSEException('Invalid signature') + } + } else if (jwt instanceof EncryptedJWT) { + log.debug 'Parsed an RSA encrypted JWT' + + EncryptedJWT encryptedJWT = jwt as EncryptedJWT + RSADecrypter decrypter = new RSADecrypter(keyProvider.privateKey) + + // Decrypt + encryptedJWT.decrypt(decrypter) + } else if (jwt instanceof PlainJWT) { + log.debug 'Parsed a plain JWT' + if (jwtSecret || keyProvider) { + throw new JOSEException('Unsigned/unencrypted JWT not expected') + } + } + + return jwt + } + + static String serialize(UserDetails userDetails) { + ByteArrayOutputStream baos = new ByteArrayOutputStream() + GZIPOutputStream gzipOut = new GZIPOutputStream(baos) + ObjectOutputStream objectOut = new ObjectOutputStream(gzipOut) + objectOut.writeObject(userDetails) + objectOut.close() + byte[] outputBytes = baos.toByteArray() + return outputBytes.encodeBase64() + } + + static UserDetails deserialize(String userDetails) { + byte[] inputBytes = userDetails.decodeBase64() + ByteArrayInputStream bais = new ByteArrayInputStream(inputBytes) + GZIPInputStream gzipIn = new GZIPInputStream(bais) + ContextClassLoaderAwareObjectInputStream objectIn = new ContextClassLoaderAwareObjectInputStream(gzipIn) + UserDetails userDetailsObject = objectIn.readObject() as UserDetails + objectIn.close() + return userDetailsObject + } + +} + +@Slf4j +class ContextClassLoaderAwareObjectInputStream extends ObjectInputStream { + + ContextClassLoaderAwareObjectInputStream(InputStream is) throws IOException { + super(is) + } + + @Override + protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { + ClassLoader currentTccl = null + try { + currentTccl = Holders.grailsApplication.classLoader + return currentTccl.loadClass(desc.name) + } catch (Exception e) { + log.debug e.message + } + + return super.resolveClass(desc) + } +} diff --git a/grails-spring-security/rest/core/grails-app/services/grails/plugin/springsecurity/rest/RestOauthService.groovy b/grails-spring-security/rest/core/grails-app/services/grails/plugin/springsecurity/rest/RestOauthService.groovy new file mode 100644 index 00000000000..8f91b0279bc --- /dev/null +++ b/grails-spring-security/rest/core/grails-app/services/grails/plugin/springsecurity/rest/RestOauthService.groovy @@ -0,0 +1,121 @@ +/* + * 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.rest + +import java.beans.PropertyDescriptor + +import groovy.util.logging.Slf4j + +import com.google.common.cache.CacheBuilder +import com.google.common.cache.LoadingCache +import org.pac4j.core.client.IndirectClient +import org.pac4j.core.context.CallContext +import org.pac4j.core.credentials.Credentials +import org.pac4j.core.profile.UserProfile + +import org.springframework.beans.BeanWrapperImpl +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder + +import grails.core.GrailsApplication +import grails.plugin.springsecurity.rest.authentication.RestAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.oauth.OauthUser +import grails.plugin.springsecurity.rest.oauth.OauthUserDetailsService +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.TokenGenerator +import grails.plugin.springsecurity.rest.token.storage.TokenStorageService +import grails.util.Holders +import grails.web.mapping.LinkGenerator + +/** + * Deals with pac4j library to fetch a user profile from the selected OAuth provider, and stores it on the security context + */ +@Slf4j +class RestOauthService { + + static transactional = false + + TokenGenerator tokenGenerator + TokenStorageService tokenStorageService + GrailsApplication grailsApplication + LinkGenerator grailsLinkGenerator + OauthUserDetailsService oauthUserDetailsService + RestAuthenticationEventPublisher authenticationEventPublisher + + private transient LoadingCache clientCache = CacheBuilder.newBuilder(). build { String provider -> + log.debug "Creating OAuth client for provider: ${provider}" + + def clientClass = grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.client"] + if (clientClass instanceof CharSequence) clientClass = Class.forName(clientClass as String, true, Holders.grailsApplication.classLoader) + IndirectClient client = (clientClass as Class).getDeclaredConstructor().newInstance() + + String callbackUrl = grailsLinkGenerator.link controller: 'restOauth', action: 'callback', params: [provider: provider], mapping: 'oauth', absolute: true + log.debug "Callback URL is: ${callbackUrl}" + client.callbackUrl = callbackUrl + + BeanWrapperImpl clientInvokerHelper = new BeanWrapperImpl(client) + for (PropertyDescriptor propertyDescriptor : clientInvokerHelper.getPropertyDescriptors()) { + if (propertyDescriptor.writeMethod) { + String propertyName = propertyDescriptor.name + if (propertyName != 'client' && grailsApplication.config.containsKey("grails.plugin.springsecurity.rest.oauth.${provider}.${propertyName}")) { + clientInvokerHelper.setPropertyValue(propertyName, grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.${propertyName}"]) + } + } + } + + client + } + + IndirectClient getClient(String provider) { + clientCache.get provider + } + + UserProfile getProfile(String provider, CallContext context) { + IndirectClient client = getClient(provider) + Credentials credentials = client.getCredentials(context).orElse(null) + client.validateCredentials(context, credentials) + + log.debug 'Querying provider to fetch User ID' + client.getUserProfile(context, credentials).orElse(null) + } + + OauthUser getOauthUser(String provider, UserProfile profile) { + def configuredDefaultRoles = grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.defaultRoles"] + List defaultRoles = configuredDefaultRoles?.collect { new SimpleGrantedAuthority(it as String) } + oauthUserDetailsService.loadUserByUserProfile(profile, defaultRoles) + } + + String storeAuthentication(String provider, CallContext context) { + UserProfile profile = getProfile(provider, context) + log.debug "User's ID: ${profile.id}" + + OauthUser userDetails = getOauthUser(provider, profile) + AccessToken accessToken = tokenGenerator.generateAccessToken(userDetails) + log.debug "Generated REST authentication token: ${accessToken}" + + log.debug 'Storing token on the token storage' + tokenStorageService.storeToken(accessToken) + + authenticationEventPublisher.publishTokenCreation(accessToken) + + SecurityContextHolder.context.setAuthentication(accessToken) + + return accessToken.accessToken + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFailureHandler.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFailureHandler.groovy new file mode 100644 index 00000000000..61878700094 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFailureHandler.groovy @@ -0,0 +1,54 @@ +/* + * 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.rest + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.authentication.AuthenticationFailureHandler + +/** + * Sets the configured status code. + */ +@Slf4j +@CompileStatic +class RestAuthenticationFailureHandler implements AuthenticationFailureHandler { + + /** + * Configurable status code, by default: conf.rest.login.failureStatusCode?:HttpServletResponse.SC_FORBIDDEN + */ + Integer statusCode + + /** + * Called when an authentication attempt fails. + * @param request the request during which the authentication attempt occurred. + * @param response the response. + * @param exception the exception which was thrown to reject the authentication request. + */ + void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + log.debug "Setting status code to ${statusCode}" + response.setStatus(statusCode) + response.addHeader('WWW-Authenticate', 'Bearer') + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFilter.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFilter.groovy new file mode 100644 index 00000000000..41a35d130f9 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFilter.groovy @@ -0,0 +1,144 @@ +/* + * 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.rest + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.authentication.AuthenticationDetailsSource +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.web.authentication.AuthenticationFailureHandler +import org.springframework.security.web.authentication.AuthenticationSuccessHandler +import org.springframework.web.filter.GenericFilterBean + +import grails.plugin.springsecurity.rest.authentication.RestAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.credentials.CredentialsExtractor +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.TokenGenerator +import grails.plugin.springsecurity.rest.token.storage.TokenStorageService + +/** + * This filter starts the initial authentication flow. It uses the configured {@link AuthenticationManager} bean, allowing + * to use any authentication provider defined by other plugins or by the application. + * + * If the authentication manager authenticates the request, a token is generated using a {@link TokenGenerator} and + * stored via {@link TokenStorageService}. Finally, a {@link AuthenticationSuccessHandler} is used to render the REST + * response to the client. + * + * If there is an authentication failure, the configured {@link AuthenticationFailureHandler} will render the response. + */ +@Slf4j +@CompileStatic +class RestAuthenticationFilter extends GenericFilterBean { + + CredentialsExtractor credentialsExtractor + + String endpointUrl + + AuthenticationManager authenticationManager + + AuthenticationSuccessHandler authenticationSuccessHandler + AuthenticationFailureHandler authenticationFailureHandler + RestAuthenticationEventPublisher authenticationEventPublisher + SpringSecurityRestFilterRequestMatcher requestMatcher + + AuthenticationDetailsSource authenticationDetailsSource + + TokenGenerator tokenGenerator + TokenStorageService tokenStorageService + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpServletRequest = request as HttpServletRequest + HttpServletResponse httpServletResponse = response as HttpServletResponse + + //Only apply filter to the configured URL + if (requestMatcher.matches(httpServletRequest)) { + log.debug 'Applying authentication filter to this request' + + //Only POST is supported + if (httpServletRequest.method != 'POST') { + log.debug "${httpServletRequest.method} HTTP method is not supported. Setting status to ${HttpServletResponse.SC_METHOD_NOT_ALLOWED}" + httpServletResponse.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED) + return + } + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication() + Authentication authenticationResult + + UsernamePasswordAuthenticationToken authenticationRequest = credentialsExtractor.extractCredentials(httpServletRequest) + + boolean authenticationRequestIsCorrect = (authenticationRequest?.principal && authenticationRequest?.credentials) + + if (authenticationRequestIsCorrect) { + authenticationRequest.details = authenticationDetailsSource.buildDetails(httpServletRequest) + + try { + log.debug 'Trying to authenticate the request' + authenticationResult = authenticationManager.authenticate(authenticationRequest) + + if (authenticationResult.authenticated) { + log.debug 'Request authenticated. Storing the authentication result in the security context' + log.debug "Authentication result: ${authenticationResult}" + + AccessToken accessToken = tokenGenerator.generateAccessToken(authenticationResult.principal as UserDetails) + log.debug "Generated token: ${accessToken}" + + tokenStorageService.storeToken(accessToken) + authenticationEventPublisher.publishTokenCreation(accessToken) + authenticationSuccessHandler.onAuthenticationSuccess(httpServletRequest, httpServletResponse, accessToken) + SecurityContextHolder.context.setAuthentication(accessToken) + } else { + log.debug 'Not authenticated. Rest authentication token not generated.' + } + } catch (AuthenticationException ae) { + log.debug "Authentication failed: ${ae.message}" + authenticationFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, ae) + } + + } else { + log.debug 'Username and/or password parameters are missing.' + if (!authentication) { + log.debug "Setting status to ${HttpServletResponse.SC_BAD_REQUEST}" + httpServletResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST) + return + } else { + log.debug 'Using authentication already in security context.' + authenticationResult = authentication + } + } + + } else { + chain.doFilter(request, response) + } + + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationProvider.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationProvider.groovy new file mode 100644 index 00000000000..c64b830ab81 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationProvider.groovy @@ -0,0 +1,97 @@ +/* + * 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.rest + +import groovy.time.TimeCategory +import groovy.time.TimeDuration +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import com.nimbusds.jwt.JWT + +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.util.Assert + +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugin.springsecurity.rest.token.storage.TokenStorageService + +/** + * Authenticates a request based on the token passed. This is called by {@link RestTokenValidationFilter}. + */ +@Slf4j +@CompileStatic +class RestAuthenticationProvider implements AuthenticationProvider { + + TokenStorageService tokenStorageService + + Boolean useJwt + JwtService jwtService + + /** + * Returns an authentication object based on the token value contained in the authentication parameter. To do so, + * it uses a {@link TokenStorageService}. + * @throws AuthenticationException + */ + Authentication authenticate(Authentication authentication) throws AuthenticationException { + log.debug "Use JWT: ${useJwt}" + Assert.isInstanceOf(AccessToken, authentication, 'Only AccessToken is supported') + AccessToken authenticationRequest = authentication as AccessToken + AccessToken authenticationResult = new AccessToken(authenticationRequest.accessToken) + + if (authenticationRequest.accessToken) { + log.debug "Trying to validate token ${authenticationRequest.accessToken}" + UserDetails userDetails = tokenStorageService.loadUserByToken(authenticationRequest.accessToken) as UserDetails + + Integer expiration = null + JWT jwt = null + if (useJwt) { + Date now = new Date() + jwt = jwtService.parse(authenticationRequest.accessToken) + + // Prevent refresh tokens from being used for authentication + if (jwt.JWTClaimsSet.getBooleanClaim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM)) { + throw new TokenNotFoundException("Token ${authenticationRequest.accessToken} is not valid") + } + + Date expiry = jwt.JWTClaimsSet.expirationTime + if (expiry) { + log.debug "Now is ${now} and token expires at ${expiry}" + + TimeDuration timeDuration = TimeCategory.minus(expiry, now) + expiration = Math.round((timeDuration.toMilliseconds() / 1000) as float) + log.debug "Expiration: ${expiration}" + } + } + + authenticationResult = new AccessToken(userDetails, userDetails.authorities, authenticationRequest.accessToken, null, expiration, jwt, null) + log.debug 'Authentication result: {}', authenticationResult + } + + return authenticationResult + } + + boolean supports(Class authentication) { + return AccessToken.isAssignableFrom(authentication) + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationSuccessHandler.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationSuccessHandler.groovy new file mode 100644 index 00000000000..0da33ea259f --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestAuthenticationSuccessHandler.groovy @@ -0,0 +1,55 @@ +/* + * 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.rest + +import groovy.transform.CompileStatic + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.AuthenticationSuccessHandler + +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.rendering.AccessTokenJsonRenderer + +/** + * Generates a JSON response using a {@link AccessTokenJsonRenderer}. + */ +@CompileStatic +class RestAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + AccessTokenJsonRenderer renderer + + /** + * Called when a user has been successfully authenticated. + * + * @param request the request which caused the successful authentication + * @param response the response + * @param authentication the Authentication object which was created during the authentication process. + */ + void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + response.contentType = 'application/json' + response.characterEncoding = 'UTF-8' + response.addHeader 'Cache-Control', 'no-store' + response.addHeader 'Pragma', 'no-cache' + response << renderer.generateJson(authentication as AccessToken) + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestLogoutFilter.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestLogoutFilter.groovy new file mode 100644 index 00000000000..3cf47b30f67 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestLogoutFilter.groovy @@ -0,0 +1,89 @@ +/* + * 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.rest + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.web.filter.GenericFilterBean + +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.reader.TokenReader +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugin.springsecurity.rest.token.storage.TokenStorageService + +/** + * Filter exposing an endpoint for deleting tokens. It will read the token from an HTTP header. If found, will delete it + * from the storage, sending a 200 response. Otherwise, it will send a 404 response. + */ +@Slf4j +@CompileStatic +class RestLogoutFilter extends GenericFilterBean { + + String endpointUrl + String headerName + TokenReader tokenReader + + TokenStorageService tokenStorageService + + SpringSecurityRestFilterRequestMatcher requestMatcher + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest servletRequest = request as HttpServletRequest + HttpServletResponse servletResponse = response as HttpServletResponse + + //Only apply filter to the configured URL + if (requestMatcher.matches(servletRequest)) { + + //Only POST is supported + if (servletRequest.method != 'POST') { + log.debug "${servletRequest.method} HTTP method is not supported. Setting status to ${HttpServletResponse.SC_METHOD_NOT_ALLOWED}" + servletResponse.setStatus HttpServletResponse.SC_METHOD_NOT_ALLOWED + return + } + + AccessToken accessToken = tokenReader.findToken(servletRequest) + + if (accessToken) { + log.debug "Token found: ${accessToken}" + + try { + log.debug 'Trying to remove the token' + tokenStorageService.removeToken accessToken.accessToken + } catch (TokenNotFoundException ignored) { + servletResponse.sendError HttpServletResponse.SC_NOT_FOUND, 'Token not found' + } + } else { + log.debug "Token is missing. Sending a ${HttpServletResponse.SC_BAD_REQUEST} Bad Request response" + servletResponse.sendError HttpServletResponse.SC_BAD_REQUEST, 'Token header is missing' + } + + } else { + chain.doFilter(request, response) + } + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestSecurityEventListener.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestSecurityEventListener.groovy new file mode 100644 index 00000000000..32d4d491c6d --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestSecurityEventListener.groovy @@ -0,0 +1,65 @@ +/* + * 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.rest + +import groovy.transform.CompileStatic + +import org.springframework.context.ApplicationContextAware +import org.springframework.context.ApplicationEvent +import org.springframework.context.ApplicationListener + +import grails.plugin.springsecurity.SecurityEventListener + +/** + * Registers as an event listener and delegates handling of security-related events + * to optional closures defined in Config.groovy. + *

+ * The following callbacks are supported:
+ *

    + *
  • onRestTokenCreationEvent
  • + *
+ * All callbacks are optional; you can implement just the ones you're interested in, e.g. + *
+ * grails {
+ *    plugin {
+ *       springsecurity {
+ *          ...
+ *          onRestTokenCreationEvent = { e, appCtx ->
+ *             ...
+ *          }
+ *       }
+ *    }
+ * }
+ * 
+ * The event and the Spring context are provided in case you need to look up a Spring bean, + * e.g. the Hibernate SessionFactory. + * + */ +@CompileStatic +public class RestSecurityEventListener extends SecurityEventListener implements ApplicationListener, ApplicationContextAware { + + void onApplicationEvent(final ApplicationEvent e) { + if (e instanceof RestTokenCreationEvent) { + call(e, 'onRestTokenCreationEvent') + } else { + super.onApplicationEvent(e) + } + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestTokenCreationEvent.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestTokenCreationEvent.groovy new file mode 100644 index 00000000000..f2d3a5bd787 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestTokenCreationEvent.groovy @@ -0,0 +1,38 @@ +/* + * 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.rest + +import org.springframework.context.ApplicationEvent + +import grails.plugin.springsecurity.rest.token.AccessToken + +/* + * Stores the {@link AccessToken} for authentication success event handlers. + */ + +class RestTokenCreationEvent extends ApplicationEvent { + + @Delegate + AccessToken accessToken + + RestTokenCreationEvent(Object source) { + super(source) + this.accessToken = (AccessToken) source + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestTokenValidationFilter.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestTokenValidationFilter.groovy new file mode 100644 index 00000000000..630c7998573 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/RestTokenValidationFilter.groovy @@ -0,0 +1,133 @@ +/* + * 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.rest + +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.authentication.AuthenticationFailureHandler +import org.springframework.security.web.authentication.AuthenticationSuccessHandler +import org.springframework.web.filter.GenericFilterBean + +import grails.plugin.springsecurity.rest.authentication.RestAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.reader.TokenReader + +/** + * This filter starts the token validation flow. It extracts the token from the configured header name, and pass it to + * the {@link RestAuthenticationProvider}. + * + * This filter, when applied, is incompatible with traditional browser-based Spring Security Core redirects. Users have + * to make sure it's applied only to REST endpoint URL's. + * + * If the authentication is successful, the result is stored in the security context and the response is generated by the + * {@link AuthenticationSuccessHandler}. Otherwise, an {@link AuthenticationFailureHandler} is called. + */ +@Slf4j +@CompileStatic +class RestTokenValidationFilter extends GenericFilterBean { + + String headerName + + RestAuthenticationProvider restAuthenticationProvider + + AuthenticationSuccessHandler authenticationSuccessHandler + AuthenticationFailureHandler authenticationFailureHandler + RestAuthenticationEventPublisher authenticationEventPublisher + + SpringSecurityRestFilterRequestMatcher requestMatcher + + TokenReader tokenReader + String validationEndpointUrl + Boolean active + + Boolean enableAnonymousAccess + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpRequest = request as HttpServletRequest + HttpServletResponse httpResponse = response as HttpServletResponse + AccessToken accessToken + + try { + accessToken = tokenReader.findToken(httpRequest) + if (accessToken) { + log.debug "Token found: ${accessToken.accessToken}" + + log.debug 'Trying to authenticate the token' + accessToken = restAuthenticationProvider.authenticate(accessToken) as AccessToken + + if (accessToken.authenticated) { + log.debug 'Token authenticated. Storing the authentication result in the security context' + log.debug "Authentication result: ${accessToken}" + SecurityContextHolder.context.setAuthentication(accessToken) + + authenticationEventPublisher.publishAuthenticationSuccess(accessToken) + + processFilterChain(request, response, chain, accessToken) + } + + } else { + log.debug 'Token not found' + processFilterChain(request, response, chain, accessToken) + } + } catch (AuthenticationException ae) { + log.debug "Authentication failed: ${ae.message}" + authenticationEventPublisher.publishAuthenticationFailure(ae, accessToken) + authenticationFailureHandler.onAuthenticationFailure(httpRequest, httpResponse, ae) + } + + } + + @CompileDynamic + private void processFilterChain(ServletRequest request, ServletResponse response, FilterChain chain, AccessToken authenticationResult) { + HttpServletRequest httpRequest = request as HttpServletRequest + HttpServletResponse httpResponse = response as HttpServletResponse + + if (!active) { + log.debug 'Token validation is disabled. Continuing the filter chain' + chain.doFilter(request, response) + return + } + + if (authenticationResult?.accessToken) { + if (requestMatcher.matches(httpRequest)) { + log.debug 'Validation endpoint called. Generating response.' + authenticationSuccessHandler.onAuthenticationSuccess(httpRequest, httpResponse, authenticationResult) + } else { + log.debug 'Continuing the filter chain' + chain.doFilter(request, response) + } + } else { + log.debug 'Request does not contain any token. Letting it continue through the filter chain' + chain.doFilter(request, response) + } + + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestFilterRequestMatcher.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestFilterRequestMatcher.groovy new file mode 100644 index 00000000000..89669ff15e0 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestFilterRequestMatcher.groovy @@ -0,0 +1,50 @@ +/* + * 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.rest + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.security.web.util.matcher.RequestMatcher + +/** + * Determines whether a given request matches against a configured endpoint URL + * + * @author Álvaro Sánchez-Mariscal + */ +@Slf4j +@CompileStatic +class SpringSecurityRestFilterRequestMatcher implements RequestMatcher { + + private String endpointUrl + + SpringSecurityRestFilterRequestMatcher(String endpointUrl) { + this.endpointUrl = endpointUrl + } + + @Override + boolean matches(HttpServletRequest request) { + String actualUri = request.requestURI - request.contextPath + log.debug "Actual URI is ${actualUri}; endpoint URL is ${endpointUrl}" + return actualUri == endpointUrl + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGrailsPlugin.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGrailsPlugin.groovy new file mode 100644 index 00000000000..20fc4d75f5b --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGrailsPlugin.groovy @@ -0,0 +1,395 @@ +/* + * 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.rest + +import groovy.util.logging.Slf4j + +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWSAlgorithm + +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.DelegatingPasswordEncoder +import org.springframework.security.crypto.password.LdapShaPasswordEncoder +import org.springframework.security.crypto.password.Md4PasswordEncoder +import org.springframework.security.crypto.password.MessageDigestPasswordEncoder +import org.springframework.security.crypto.password.NoOpPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder +import org.springframework.security.crypto.password.StandardPasswordEncoder +import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder +import org.springframework.security.web.access.AccessDeniedHandlerImpl +import org.springframework.security.web.access.ExceptionTranslationFilter +import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint +import org.springframework.security.web.savedrequest.NullRequestCache + +import grails.core.GrailsApplication +import grails.plugin.springsecurity.BeanTypeResolver +import grails.plugin.springsecurity.SecurityFilterPosition +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.rest.authentication.DefaultRestAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.authentication.NullRestAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.credentials.DefaultJsonPayloadCredentialsExtractor +import grails.plugin.springsecurity.rest.credentials.RequestParamsCredentialsExtractor +import grails.plugin.springsecurity.rest.error.DefaultCallbackErrorHandler +import grails.plugin.springsecurity.rest.oauth.DefaultOauthUserDetailsService +import grails.plugin.springsecurity.rest.token.bearer.BearerTokenAccessDeniedHandler +import grails.plugin.springsecurity.rest.token.bearer.BearerTokenAuthenticationEntryPoint +import grails.plugin.springsecurity.rest.token.bearer.BearerTokenAuthenticationFailureHandler +import grails.plugin.springsecurity.rest.token.bearer.BearerTokenReader +import grails.plugin.springsecurity.rest.token.generation.SecureRandomTokenGenerator +import grails.plugin.springsecurity.rest.token.generation.TokenGenerator +import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.generation.jwt.CustomClaimProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.DefaultRSAKeyProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.EncryptedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.generation.jwt.FileRSAKeyProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.IssuerClaimProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.SignedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.reader.HttpHeaderTokenReader +import grails.plugin.springsecurity.rest.token.rendering.DefaultAccessTokenJsonRenderer +import grails.plugin.springsecurity.rest.token.storage.jwt.JwtTokenStorageService +import grails.plugins.Plugin + +@Slf4j +class SpringSecurityRestGrailsPlugin extends Plugin { + + // the version or versions of Grails the plugin is designed for + String grailsVersion = '7.0.0 > *' + List loadAfter = ['springSecurityCore'] + List pluginExcludes = [ + 'grails-app/views/**' + ] + + String title = 'Spring Security REST Plugin' + String author = 'Alvaro Sanchez-Mariscal' + String authorEmail = '' + String description = 'Implements authentication for REST APIs based on Spring Security. It uses a token-based workflow' + + def profiles = ['web'] + + // URL to the plugin's documentation + String documentation = 'https://apache.github.io/grails-spring-security' + + // Extra (optional) plugin metadata + String license = 'APACHE' + def organization = [name: 'Grails', url: 'https://www.grails.org'] + + def issueManagement = [system: 'GitHub', url: 'https://github.com/apache/grails-spring-security/issues'] + def scm = [url: 'https://github.com/apache/grails-spring-security'] + GrailsApplication grailsApplication + + Closure doWithSpring() { + { -> + if (!springSecurityPluginsAreActive()) { + return + } + + def conf = SpringSecurityUtils.securityConfig + SpringSecurityUtils.loadSecondaryConfig 'DefaultRestSecurityConfig' + conf = SpringSecurityUtils.securityConfig + + boolean printStatusMessages = (conf.printStatusMessages instanceof Boolean) ? conf.printStatusMessages : true + + if (printStatusMessages) { + println "\nConfiguring Spring Security REST ${plugin.version}..." + } + + ///* + SpringSecurityUtils.registerProvider 'restAuthenticationProvider' + + /* restAuthenticationFilter */ + if (conf.rest.login.active) { + SpringSecurityUtils.registerFilter 'restAuthenticationFilter', SecurityFilterPosition.FORM_LOGIN_FILTER.order + 1 + + restAuthenticationFilterRequestMatcher(SpringSecurityRestFilterRequestMatcher, conf.rest.login.endpointUrl) + + restAuthenticationFilter(RestAuthenticationFilter) { + authenticationManager = ref('authenticationManager') + authenticationSuccessHandler = ref('restAuthenticationSuccessHandler') + authenticationFailureHandler = ref('restAuthenticationFailureHandler') + authenticationDetailsSource = ref('authenticationDetailsSource') + credentialsExtractor = ref('credentialsExtractor') + endpointUrl = conf.rest.login.endpointUrl + tokenGenerator = ref('tokenGenerator') + tokenStorageService = ref('tokenStorageService') + authenticationEventPublisher = ref('authenticationEventPublisher') + requestMatcher = ref('restAuthenticationFilterRequestMatcher') + } + + def paramsClosure = { + usernamePropertyName = conf.rest.login.usernamePropertyName // username + passwordPropertyName = conf.rest.login.passwordPropertyName // password + } + + if (conf.rest.login.useRequestParamsCredentials) { + credentialsExtractor(RequestParamsCredentialsExtractor, paramsClosure) + } else if (conf.rest.login.useJsonCredentials) { + credentialsExtractor(DefaultJsonPayloadCredentialsExtractor, paramsClosure) + } + + /* restLogoutFilter */ + restLogoutFilterRequestMatcher(SpringSecurityRestFilterRequestMatcher, conf.rest.logout.endpointUrl) + + restLogoutFilter(RestLogoutFilter) { + endpointUrl = conf.rest.logout.endpointUrl + headerName = conf.rest.token.validation.headerName + tokenStorageService = ref('tokenStorageService') + tokenReader = ref('tokenReader') + requestMatcher = ref('restLogoutFilterRequestMatcher') + } + } + + restAuthenticationSuccessHandler(RestAuthenticationSuccessHandler) { + renderer = ref('accessTokenJsonRenderer') + } + + accessTokenJsonRenderer(DefaultAccessTokenJsonRenderer) { + usernamePropertyName = conf.rest.token.rendering.usernamePropertyName + tokenPropertyName = conf.rest.token.rendering.tokenPropertyName + authoritiesPropertyName = conf.rest.token.rendering.authoritiesPropertyName + useBearerToken = conf.rest.token.validation.useBearerToken + } + + if (conf.rest.token.validation.useBearerToken) { + tokenReader(BearerTokenReader) + restAuthenticationFailureHandler(BearerTokenAuthenticationFailureHandler) { + tokenReader = ref('tokenReader') + } + restAuthenticationEntryPoint(BearerTokenAuthenticationEntryPoint) { + tokenReader = ref('tokenReader') + } + restAccessDeniedHandler(BearerTokenAccessDeniedHandler) { + errorPage = null //403 + } + + } else { + restAuthenticationEntryPoint(Http403ForbiddenEntryPoint) + tokenReader(HttpHeaderTokenReader) { + headerName = conf.rest.token.validation.headerName + } + restAuthenticationFailureHandler(RestAuthenticationFailureHandler) { + statusCode = conf.rest.login.failureStatusCode ?: HttpServletResponse.SC_UNAUTHORIZED + } + restAccessDeniedHandler(AccessDeniedHandlerImpl) { + errorPage = null //403 + } + } + + /* restTokenValidationFilter */ + SpringSecurityUtils.registerFilter 'restTokenValidationFilter', SecurityFilterPosition.ANONYMOUS_FILTER.order + 1 + SpringSecurityUtils.registerFilter 'restExceptionTranslationFilter', SecurityFilterPosition.EXCEPTION_TRANSLATION_FILTER.order - 5 + + restTokenValidationFilterRequestMatcher(SpringSecurityRestFilterRequestMatcher, conf.rest.token.validation.endpointUrl) + + restTokenValidationFilter(RestTokenValidationFilter) { + headerName = conf.rest.token.validation.headerName + validationEndpointUrl = conf.rest.token.validation.endpointUrl + active = conf.rest.token.validation.active + tokenReader = ref('tokenReader') + enableAnonymousAccess = conf.rest.token.validation.enableAnonymousAccess + authenticationSuccessHandler = ref('restAuthenticationSuccessHandler') + authenticationFailureHandler = ref('restAuthenticationFailureHandler') + restAuthenticationProvider = ref('restAuthenticationProvider') + authenticationEventPublisher = ref('authenticationEventPublisher') + requestMatcher = ref('restTokenValidationFilterRequestMatcher') + } + + restExceptionTranslationFilter(ExceptionTranslationFilter, ref('restAuthenticationEntryPoint'), ref('restRequestCache')) { + accessDeniedHandler = ref('restAccessDeniedHandler') + authenticationTrustResolver = ref('authenticationTrustResolver') + throwableAnalyzer = ref('throwableAnalyzer') + } + + restRequestCache(NullRequestCache) + + /* tokenGenerator */ + tokenGenerator(SecureRandomTokenGenerator) + + callbackErrorHandler(DefaultCallbackErrorHandler) + + String jwtSecretValue = conf.rest.token.storage.jwt.secret + + /* tokenStorageService - defaults to JWT */ + jwtService(JwtService) { + jwtSecret = jwtSecretValue + } + tokenStorageService(JwtTokenStorageService) { + jwtService = ref('jwtService') + userDetailsService = ref('userDetailsService') + } + + issuerClaimProvider(IssuerClaimProvider) { + issuerName = conf.rest.token.generation.jwt.issuer + } + + if (conf.rest.token.storage.jwt.useEncryptedJwt) { + jwtService(JwtService) { + keyProvider = ref('keyProvider') + } + tokenGenerator(EncryptedJwtTokenGenerator) { + jwtTokenStorageService = ref('tokenStorageService') + keyProvider = ref('keyProvider') + defaultExpiration = conf.rest.token.storage.jwt.expiration + defaultRefreshExpiration = conf.rest.token.storage.jwt.refreshExpiration + jweAlgorithm = JWEAlgorithm.parse(conf.rest.token.generation.jwt.jweAlgorithm) + encryptionMethod = EncryptionMethod.parse(conf.rest.token.generation.jwt.encryptionMethod) + } + + if (conf.rest.token.storage.jwt.privateKeyPath instanceof CharSequence && + conf.rest.token.storage.jwt.publicKeyPath instanceof CharSequence) { + keyProvider(FileRSAKeyProvider) { + privateKeyPath = conf.rest.token.storage.jwt.privateKeyPath + publicKeyPath = conf.rest.token.storage.jwt.publicKeyPath + } + } else { + keyProvider(DefaultRSAKeyProvider) + } + + } else if (conf.rest.token.storage.jwt.useSignedJwt) { + checkJwtSecret(jwtSecretValue) + + tokenGenerator(SignedJwtTokenGenerator) { + jwtTokenStorageService = ref('tokenStorageService') + jwtSecret = jwtSecretValue + defaultExpiration = conf.rest.token.storage.jwt.expiration + defaultRefreshExpiration = conf.rest.token.storage.jwt.refreshExpiration + jwsAlgorithm = JWSAlgorithm.parse(conf.rest.token.generation.jwt.algorithm) + } + } + + /* restAuthenticationProvider */ + restAuthenticationProvider(RestAuthenticationProvider) { + tokenStorageService = ref('tokenStorageService') + useJwt = true + jwtService = ref('jwtService') + } + + /* oauthUserDetailsService */ + oauthUserDetailsService(DefaultOauthUserDetailsService) { + userDetailsService = ref('userDetailsService') + preAuthenticationChecks = ref('preAuthenticationChecks') + } + + // SecurityEventListener + if (conf.useSecurityEventListener) { + restSecurityEventListener(RestSecurityEventListener) + + authenticationEventPublisher(DefaultRestAuthenticationEventPublisher) + } else { + authenticationEventPublisher(NullRestAuthenticationEventPublisher) + } + + String algorithm = conf.password.algorithm + Class beanTypeResolverClass = conf.beanTypeResolverClass ?: BeanTypeResolver + def beanTypeResolver = beanTypeResolverClass.newInstance(conf, grailsApplication) + + passwordEncoder(beanTypeResolver.resolveType('passwordEncoder', DelegatingPasswordEncoder), algorithm, idToPasswordEncoder(conf)) + + if (printStatusMessages) { + println '... finished configuring Spring Security REST\n' + } + + } + } + + @Override + void doWithApplicationContext() { + if (!springSecurityPluginsAreActive()) { + return + } + def customClaimProvidersList = applicationContext.getBeanNamesForType(CustomClaimProvider).collect { + applicationContext.getBean(it, CustomClaimProvider) + } + log.debug 'customClaimProvidersList = {}', customClaimProvidersList + + TokenGenerator tokenGenerator = applicationContext.getBean('tokenGenerator') as TokenGenerator + + if (tokenGenerator instanceof AbstractJwtTokenGenerator) { + tokenGenerator.customClaimProviders = customClaimProvidersList + } + + } + + private void checkJwtSecret(String jwtSecretValue) { + if (!jwtSecretValue && + !pluginManager.hasGrailsPlugin('springSecurityRestGorm') && + !pluginManager.hasGrailsPlugin('springSecurityRestGrailsCache') && + !pluginManager.hasGrailsPlugin('springSecurityRestRedis') && + !pluginManager.hasGrailsPlugin('springSecurityRestMemcached')) { + throw new Exception('A JWT secret must be defined. Please provide a value for the config property: grails.plugin.springsecurity.rest.token.storage.jwt.secret') + } + } + + Map idToPasswordEncoder(ConfigObject conf) { + + final String ENCODING_ID_BCRYPT = 'bcrypt' + final String ENCODING_ID_LDAP = 'ldap' + final String ENCODING_ID_MD4 = 'MD4' + final String ENCODING_ID_MD5 = 'MD5' + final String ENCODING_ID_NOOP = 'noop' + final String ENCODING_ID_PBKDF2 = 'pbkdf2' + final String ENCODING_ID_SCRYPT = 'scrypt' + final String ENCODING_ID_ARGON2 = 'argon2' + final String ENCODING_ID_SHA1 = 'SHA-1' + final String ENCODING_IDSHA256 = 'SHA-256' + + MessageDigestPasswordEncoder messageDigestPasswordEncoderMD5 = new MessageDigestPasswordEncoder(ENCODING_ID_MD5) + messageDigestPasswordEncoderMD5.encodeHashAsBase64 = conf.password.encodeHashAsBase64 // false + messageDigestPasswordEncoderMD5.iterations = conf.password.hash.iterations // 10000 + + MessageDigestPasswordEncoder messsageDigestPasswordEncoderSHA1 = new MessageDigestPasswordEncoder(ENCODING_ID_SHA1) + messsageDigestPasswordEncoderSHA1.encodeHashAsBase64 = conf.password.encodeHashAsBase64 // false + messsageDigestPasswordEncoderSHA1.iterations = conf.password.hash.iterations // 10000 + + MessageDigestPasswordEncoder messsageDigestPasswordEncoderSHA256 = new MessageDigestPasswordEncoder(ENCODING_IDSHA256) + messsageDigestPasswordEncoderSHA256.encodeHashAsBase64 = conf.password.encodeHashAsBase64 // false + messsageDigestPasswordEncoderSHA256.iterations = conf.password.hash.iterations // 10000 + + int strength = conf.password.bcrypt.logrounds + [(ENCODING_ID_BCRYPT): new BCryptPasswordEncoder(strength), + (ENCODING_ID_LDAP): new LdapShaPasswordEncoder(), + (ENCODING_ID_MD4): new Md4PasswordEncoder(), + (ENCODING_ID_MD5): messageDigestPasswordEncoderMD5, + (ENCODING_ID_NOOP): NoOpPasswordEncoder.getInstance(), + (ENCODING_ID_PBKDF2): Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8(), + (ENCODING_ID_SCRYPT): SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8(), + (ENCODING_ID_ARGON2): Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(), + (ENCODING_ID_SHA1): messsageDigestPasswordEncoderSHA1, + (ENCODING_IDSHA256): messsageDigestPasswordEncoderSHA256, + 'sha256': new StandardPasswordEncoder()] + } + + private boolean springSecurityPluginsAreActive() { + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active) { + return false + } + + SpringSecurityUtils.loadSecondaryConfig 'DefaultRestSecurityConfig' + conf = SpringSecurityUtils.securityConfig + + if (!conf.rest.active) { + return false + } + return true + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.groovy new file mode 100644 index 00000000000..c507ab24ea6 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisher.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.rest.authentication + +import groovy.transform.CompileStatic + +import org.springframework.context.ApplicationEventPublisher +import org.springframework.security.authentication.DefaultAuthenticationEventPublisher + +import grails.plugin.springsecurity.rest.RestTokenCreationEvent +import grails.plugin.springsecurity.rest.token.AccessToken + +/* + * Default implementation of the {@link RestAuthenticationEventPublisher}. + */ + +@CompileStatic +class DefaultRestAuthenticationEventPublisher extends DefaultAuthenticationEventPublisher implements RestAuthenticationEventPublisher { + + private ApplicationEventPublisher applicationEventPublisher + + DefaultRestAuthenticationEventPublisher() { + super() + } + + DefaultRestAuthenticationEventPublisher(ApplicationEventPublisher publisher) { + super(publisher) + this.applicationEventPublisher = publisher + } + + void setApplicationEventPublisher(ApplicationEventPublisher publisher) { + super.setApplicationEventPublisher(publisher) + this.applicationEventPublisher = publisher + } + + void publishTokenCreation(AccessToken accessToken) { + if (applicationEventPublisher != null) { + applicationEventPublisher.publishEvent(new RestTokenCreationEvent(accessToken)) + } + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/NullRestAuthenticationEventPublisher.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/NullRestAuthenticationEventPublisher.groovy new file mode 100644 index 00000000000..40fbce53fd9 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/NullRestAuthenticationEventPublisher.groovy @@ -0,0 +1,37 @@ +/* + * 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.rest.authentication + +import groovy.transform.CompileStatic + +import grails.plugin.springsecurity.authentication.NullAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.token.AccessToken + +/* + * Default implementation of the {@link RestAuthenticationEventPublisher} if security events are not listened to. + */ + +@CompileStatic +class NullRestAuthenticationEventPublisher extends NullAuthenticationEventPublisher implements RestAuthenticationEventPublisher { + + @Override + void publishTokenCreation(AccessToken accessToken) { + // do nothing + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/RestAuthenticationEventPublisher.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/RestAuthenticationEventPublisher.groovy new file mode 100644 index 00000000000..c162ab8d31a --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/authentication/RestAuthenticationEventPublisher.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 grails.plugin.springsecurity.rest.authentication + +import groovy.transform.CompileStatic + +import org.springframework.security.authentication.AuthenticationEventPublisher + +import grails.plugin.springsecurity.rest.token.AccessToken + +@CompileStatic +public interface RestAuthenticationEventPublisher extends AuthenticationEventPublisher { + + void publishTokenCreation(AccessToken accessToken) +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/AbstractJsonPayloadCredentialsExtractor.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/AbstractJsonPayloadCredentialsExtractor.groovy new file mode 100644 index 00000000000..358c8973481 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/AbstractJsonPayloadCredentialsExtractor.groovy @@ -0,0 +1,36 @@ +/* + * 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.rest.credentials + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest + +import grails.converters.JSON + +/** + * Base class for JSON-based credentials extractors. It helps building a JSON object from the request body + */ +@CompileStatic +abstract class AbstractJsonPayloadCredentialsExtractor implements CredentialsExtractor { + + Object getJsonBody(HttpServletRequest httpServletRequest) { + JSON.parse(httpServletRequest) + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/CredentialsExtractor.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/CredentialsExtractor.groovy new file mode 100644 index 00000000000..dfb7d674e1f --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/CredentialsExtractor.groovy @@ -0,0 +1,33 @@ +/* + * 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.rest.credentials + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken + +/** + * Extracts username and password from the request and creates and {@link UsernamePasswordAuthenticationToken} + * object + */ +public interface CredentialsExtractor { + + UsernamePasswordAuthenticationToken extractCredentials(HttpServletRequest httpServletRequest) + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/DefaultJsonPayloadCredentialsExtractor.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/DefaultJsonPayloadCredentialsExtractor.groovy new file mode 100644 index 00000000000..12a301ae68a --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/DefaultJsonPayloadCredentialsExtractor.groovy @@ -0,0 +1,54 @@ +/* + * 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.rest.credentials + +import groovy.util.logging.Slf4j + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken + +//tag::class[] +/** + * Extracts credentials from a JSON request like: {"username": "foo", "password": "bar"} + */ +@Slf4j +class DefaultJsonPayloadCredentialsExtractor extends AbstractJsonPayloadCredentialsExtractor { + + String usernamePropertyName + String passwordPropertyName + + UsernamePasswordAuthenticationToken extractCredentials(HttpServletRequest httpServletRequest) { + def jsonBody = getJsonBody(httpServletRequest) + + if (jsonBody) { + String username = jsonBody."${usernamePropertyName}" + String password = jsonBody."${passwordPropertyName}" + + log.debug "Extracted credentials from JSON payload. Username: ${username}, password: ${password?.size() ? '[PROTECTED]' : '[MISSING]'}" + + new UsernamePasswordAuthenticationToken(username, password) + } else { + log.debug 'No JSON body sent in the request' + return null + } + } + +} +//end::class[] diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/RequestParamsCredentialsExtractor.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/RequestParamsCredentialsExtractor.groovy new file mode 100644 index 00000000000..3b97e63934f --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/credentials/RequestParamsCredentialsExtractor.groovy @@ -0,0 +1,47 @@ +/* + * 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.rest.credentials + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken + +/** + * Extracts credentials from request parameters + */ +@Slf4j +@CompileStatic +class RequestParamsCredentialsExtractor implements CredentialsExtractor { + + String usernamePropertyName + String passwordPropertyName + + UsernamePasswordAuthenticationToken extractCredentials(HttpServletRequest httpServletRequest) { + String username = httpServletRequest.getParameter(usernamePropertyName) + String password = httpServletRequest.getParameter(passwordPropertyName) + + log.debug "Extracted credentials from request params. Username: ${username}, password: ${password?.size() ? '[PROTECTED]' : '[MISSING]'}" + + new UsernamePasswordAuthenticationToken(username, password) + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/error/CallbackErrorHandler.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/error/CallbackErrorHandler.groovy new file mode 100644 index 00000000000..aa5371a6e7b --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/error/CallbackErrorHandler.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 grails.plugin.springsecurity.rest.error + +interface CallbackErrorHandler { + + /** + * Converts an error that occurs during the callback to a parameter map that will be returned to the frontend + * @param cause + * @return + * + * @author Dónal Murtagh + */ + Map convert(Exception cause) +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/error/DefaultCallbackErrorHandler.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/error/DefaultCallbackErrorHandler.groovy new file mode 100644 index 00000000000..566ebaba662 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/error/DefaultCallbackErrorHandler.groovy @@ -0,0 +1,53 @@ +/* + * 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.rest.error + +import org.springframework.security.core.userdetails.UsernameNotFoundException + +import static org.springframework.http.HttpStatus.FORBIDDEN +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR + +/** + * Error handler that's backwardly compatible with the behaviour that was embedded within the callback action of + * RestOauthController up to and including version 1.5.2 + * + * @author Dónal Murtagh + */ + +class DefaultCallbackErrorHandler implements CallbackErrorHandler { + + @Override + Map convert(Exception e) { + + Map params = [:] + + if (e instanceof UsernameNotFoundException) { + params.error = FORBIDDEN.value() + + } else { + params.error = e.cause?.hasProperty('code') ? e.cause.code : INTERNAL_SERVER_ERROR.value() + } + + // Add the error message under the keys 'error_description' and 'message' - the former for compatibility with + // the RFC and the latter for backwards compatibility with plugin versions <= 1.5.2 + params.error_description = params.message = e.message ?: '' + params.error_code = e.cause ? e.cause.class.simpleName : e.class.simpleName + params + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/DefaultOauthUserDetailsService.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/DefaultOauthUserDetailsService.groovy new file mode 100644 index 00000000000..cd786027af9 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/DefaultOauthUserDetailsService.groovy @@ -0,0 +1,91 @@ +/* + * 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.rest.oauth + +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.pac4j.core.profile.UserProfile + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsChecker +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException + +import grails.plugin.springsecurity.SpringSecurityUtils + +//tag::class[] +/** + * Builds an {@link OauthUser}. Delegates to the default {@link UserDetailsService#loadUserByUsername(java.lang.String)} + * where the username passed is {@link UserProfile#getId()}. + * + * If the user is not found, it will create a new one with the the default roles. + */ +@Slf4j +@CompileStatic +class DefaultOauthUserDetailsService implements OauthUserDetailsService { + + @Delegate + UserDetailsService userDetailsService + + UserDetailsChecker preAuthenticationChecks + + OauthUser loadUserByUserProfile(UserProfile userProfile, Collection defaultRoles) + throws UsernameNotFoundException { + + String userDomainClass = userDomainClassName() + if (!userDomainClass) { + return instantiateOauthUser(userProfile, defaultRoles) + } + loadUserByUserProfileWhenUserDomainClassIsSet(userProfile, defaultRoles) + } + + OauthUser loadUserByUserProfileWhenUserDomainClassIsSet(UserProfile userProfile, Collection defaultRoles) { + OauthUser oauthUser + try { + log.debug "Trying to fetch user details for user profile: ${userProfile}" + UserDetails userDetails = userDetailsService.loadUserByUsername userProfile.id + + log.debug "Checking user details with ${preAuthenticationChecks.class.name}" + preAuthenticationChecks?.check(userDetails) + + Collection allRoles = [] + allRoles.addAll(userDetails.authorities) + allRoles.addAll(defaultRoles) + oauthUser = new OauthUser(userDetails.username, userDetails.password, allRoles, userProfile) + } catch (UsernameNotFoundException ignored) { + log.debug "User not found. Creating a new one with default roles: ${defaultRoles}" + oauthUser = instantiateOauthUser(userProfile, defaultRoles) + } + oauthUser + } + + OauthUser instantiateOauthUser(UserProfile userProfile, Collection defaultRoles) { + new OauthUser(userProfile.id, 'N/A', defaultRoles, userProfile) + } + + @CompileDynamic + String userDomainClassName() { + SpringSecurityUtils.getSecurityConfig()?.get('userLookup')?.get('userDomainClassName') + } + +} +//end::class[] diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/OauthUser.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/OauthUser.groovy new file mode 100644 index 00000000000..33f363ce1b1 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/OauthUser.groovy @@ -0,0 +1,54 @@ +/* + * 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.rest.oauth + +import groovy.transform.CompileStatic + +import org.pac4j.core.profile.CommonProfile +import org.pac4j.core.profile.UserProfile + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails + +/** + * A {@link UserDetails} implementation that holds the {@link CommonProfile} returned by the OAuth provider + */ + +@CompileStatic +class OauthUser extends User implements Serializable { + + private static final long serialVersionUID = 1055519971558835240L + + CommonProfile userProfile + + OauthUser(String username, String password, Collection authorities) { + super(username, password, authorities) + } + + OauthUser(String username, String password, Collection authorities, UserProfile userProfile) { + super(username, password, authorities) + if (userProfile && !(userProfile instanceof CommonProfile)) { + throw new IllegalStateException('The userProfile must be an instance of CommonProfile to support display name and email') + } + + this.userProfile = userProfile as CommonProfile + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/OauthUserDetailsService.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/OauthUserDetailsService.groovy new file mode 100644 index 00000000000..54d5639be17 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/oauth/OauthUserDetailsService.groovy @@ -0,0 +1,43 @@ +/* + * 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.rest.oauth + +import org.pac4j.core.profile.UserProfile + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.core.userdetails.UsernameNotFoundException + +/** + * Load users based on a OAuth20Profile + */ +interface OauthUserDetailsService extends UserDetailsService { + + /** + * Search for a user based on his OAuth20Profile. Implementations have a chance here to define additional + * checks, like verifying that user's email domain is valid. + * + * @param userProfile user's OAuth profile returned by OAuth provider. + * @param defaultRoles + * @return a valid {@link OauthUser}, otherwise a exception is thrown + * @throws UsernameNotFoundException if the user is not found and/or not allowed to login based on his profile + */ + OauthUser loadUserByUserProfile(UserProfile userProfile, Collection defaultRoles) throws UsernameNotFoundException + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/AccessToken.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/AccessToken.groovy new file mode 100644 index 00000000000..229cd3c51aa --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/AccessToken.groovy @@ -0,0 +1,85 @@ +/* + * 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.rest.token + +import groovy.transform.CompileStatic +import groovy.transform.ToString + +import com.nimbusds.jwt.JWT + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +/** + * Encapsulates an OAuth 2.0 access token. + */ +@ToString(includeNames = true, includeSuper = true, includes = ['principal', 'accessToken', 'accessTokenJwt', 'refreshToken', 'refreshTokenJwt', 'expiration']) +@CompileStatic +class AccessToken extends AbstractAuthenticationToken { + + static final long serialVersionUID = 6369159577577756817L + + String accessToken + JWT accessTokenJwt + + Integer expiration + + String refreshToken + JWT refreshTokenJwt + + /** The username */ + UserDetails principal + + AccessToken(Collection authorities) { + super(authorities) + super.setAuthenticated(true) + } + + AccessToken(UserDetails principal, Collection authorities, String accessToken, String refreshToken = null, Integer expiration = null, JWT accessTokenJwt = null, JWT refreshTokenJwt = null) { + this(authorities) + this.principal = principal + this.accessToken = accessToken + this.refreshToken = refreshToken + this.expiration = expiration + this.accessTokenJwt = accessTokenJwt + this.refreshTokenJwt = refreshTokenJwt + } + + AccessToken(String accessToken, String refreshToken = null, Integer expiration = null) { + super(null as Collection) + this.accessToken = accessToken + this.refreshToken = refreshToken + this.expiration = expiration + super.setAuthenticated(false) + } + + Object getCredentials() { + return null + } + + void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { + if (isAuthenticated) { + throw new IllegalArgumentException('Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead') + } + + super.setAuthenticated(false) + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAccessDeniedHandler.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAccessDeniedHandler.groovy new file mode 100644 index 00000000000..d904a5b7f0c --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAccessDeniedHandler.groovy @@ -0,0 +1,41 @@ +/* + * 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.rest.token.bearer + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandlerImpl + +@Slf4j +@CompileStatic +class BearerTokenAccessDeniedHandler extends AccessDeniedHandlerImpl { + + @Override + void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.addHeader('WWW-Authenticate', 'Bearer error="insufficient_scope"') + super.handle(request, response, accessDeniedException) + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAuthenticationEntryPoint.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAuthenticationEntryPoint.groovy new file mode 100644 index 00000000000..9ee2a1d9cac --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAuthenticationEntryPoint.groovy @@ -0,0 +1,55 @@ +/* + * 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.rest.token.bearer + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint + +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * Handles prompting the client for authentication when using bearer tokens. + */ +@Slf4j +@CompileStatic +class BearerTokenAuthenticationEntryPoint implements AuthenticationEntryPoint { + + BearerTokenReader tokenReader + + @Override + void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) + throws IOException, ServletException { + AccessToken accessToken = tokenReader.findToken(request) + + if (accessToken) { + response.addHeader('WWW-Authenticate', 'Bearer error="invalid_token"') + } else { + response.addHeader('WWW-Authenticate', 'Bearer') + } + response.sendError(HttpServletResponse.SC_UNAUTHORIZED) + + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAuthenticationFailureHandler.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAuthenticationFailureHandler.groovy new file mode 100644 index 00000000000..7208b8f5a13 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAuthenticationFailureHandler.groovy @@ -0,0 +1,68 @@ +/* + * 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.rest.token.bearer + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.authentication.AuthenticationFailureHandler + +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * Handles authentication failure when BearerToken authentication is enabled. + */ +@Slf4j +@CompileStatic +class BearerTokenAuthenticationFailureHandler implements AuthenticationFailureHandler { + + BearerTokenReader tokenReader + + /** + * Sends the proper response code and headers, as defined by RFC6750. + * + * @param request + * @param response + * @param e + * @throws IOException + * @throws ServletException + */ + @Override + void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { + + String headerValue + AccessToken accessToken = tokenReader.findToken(request) + + if (accessToken) { + headerValue = 'Bearer error="invalid_token"' + } else { + headerValue = 'Bearer' + } + + response.addHeader('WWW-Authenticate', headerValue) + response.status = HttpServletResponse.SC_UNAUTHORIZED + + log.debug "Sending status code ${response.status} and header WWW-Authenticate: ${response.getHeader('WWW-Authenticate')}" + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenReader.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenReader.groovy new file mode 100644 index 00000000000..4574a9ee77e --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenReader.groovy @@ -0,0 +1,69 @@ +/* + * 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.rest.token.bearer + +import groovy.util.logging.Slf4j + +import jakarta.servlet.http.HttpServletRequest + +import org.springframework.http.MediaType + +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.reader.TokenReader + +/** + * RFC 6750 implementation of a {@link TokenReader} + */ +@Slf4j +class BearerTokenReader implements TokenReader { + + /** + * Finds the bearer token within the specified request. It will attempt to look in all places allowed by the + * specification: Authorization header, form encoded body, and query string. + * + * @param request + * @return the token if found, null otherwise + */ + @Override + AccessToken findToken(HttpServletRequest request) { + log.debug 'Looking for bearer token in Authorization header, query string or Form-Encoded body parameter' + String tokenValue = null + String header = request.getHeader('Authorization') + + if (header?.startsWith('Bearer') && header.length() >= 8) { + log.debug 'Found bearer token in Authorization header' + tokenValue = header.substring(7) + } else if (isFormEncoded(request) && !request.get) { + log.debug 'Found bearer token in request body' + tokenValue = request.parameterMap['access_token']?.first() + } else if (request.queryString?.contains('access_token')) { + log.debug 'Found bearer token in query string' + tokenValue = request.getParameter('access_token') + } else { + log.debug 'No token found' + } + + log.debug "Token: ${tokenValue}" + return tokenValue ? new AccessToken(tokenValue) : null + } + + private boolean isFormEncoded(HttpServletRequest servletRequest) { + servletRequest.contentType && MediaType.parseMediaType(servletRequest.contentType).isCompatibleWith(MediaType.APPLICATION_FORM_URLENCODED) + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/SecureRandomTokenGenerator.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/SecureRandomTokenGenerator.groovy new file mode 100644 index 00000000000..2c6516532f2 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/SecureRandomTokenGenerator.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 grails.plugin.springsecurity.rest.token.generation + +import java.security.SecureRandom + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.apache.commons.lang3.RandomStringUtils + +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * A {@link TokenGenerator} implementation using {@link java.security.SecureRandom} + */ +@Slf4j +@CompileStatic +class SecureRandomTokenGenerator implements TokenGenerator { + + SecureRandom random = new SecureRandom() + + /** + * Generates a token using {@link java.security.SecureRandom}, a cryptographically strong random number generator. + * + * @return a String token of 32 alphanumeric characters. + */ + @Override + AccessToken generateAccessToken(UserDetails principal) { + String token = new BigInteger(160, this.random).toString(32) + def tokenSize = token.size() + if (tokenSize < 32) token += RandomStringUtils.randomAlphanumeric(32 - tokenSize) + return new AccessToken(principal, principal.authorities, token) + } + + @Override + AccessToken generateAccessToken(UserDetails principal, Integer expiration) { + AccessToken accessToken = generateAccessToken(principal) + accessToken.expiration = expiration + return accessToken + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/TokenGenerator.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/TokenGenerator.groovy new file mode 100644 index 00000000000..065f641a6b0 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/TokenGenerator.groovy @@ -0,0 +1,42 @@ +/* + * 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.rest.token.generation + +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * Implementations of this interface must provide a token generation strategy + */ +interface TokenGenerator { + + /** + * Generates a globally unique token. + */ + AccessToken generateAccessToken(UserDetails principal) + + /** + * Generates a token with the given expiration + * + * @param expiration the expiration time in seconds + */ + AccessToken generateAccessToken(UserDetails principal, Integer expiration) + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/UUIDTokenGenerator.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/UUIDTokenGenerator.groovy new file mode 100644 index 00000000000..b63b733e9f8 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/UUIDTokenGenerator.groovy @@ -0,0 +1,51 @@ +/* + * 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.rest.token.generation + +import groovy.transform.CompileStatic + +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * Uses {@link UUID} to generate tokens. + */ + +@CompileStatic +class UUIDTokenGenerator implements TokenGenerator { + + /** + * Generates a UUID based token + * + * @return a String token of 32 alphanumeric characters. + */ + AccessToken generateAccessToken(UserDetails principal) { + String token = UUID.randomUUID().toString().replaceAll('-', '') + return new AccessToken(principal, principal.authorities, token) + } + + @Override + AccessToken generateAccessToken(UserDetails principal, Integer expiration) { + AccessToken accessToken = generateAccessToken(principal) + accessToken.expiration = expiration + return accessToken + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/AbstractJwtTokenGenerator.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/AbstractJwtTokenGenerator.groovy new file mode 100644 index 00000000000..38dd9551590 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/AbstractJwtTokenGenerator.groovy @@ -0,0 +1,127 @@ +/* + * 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.rest.token.generation.jwt + +import groovy.time.TimeCategory +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import com.nimbusds.jwt.JWT +import com.nimbusds.jwt.JWTClaimsSet + +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.JwtService +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.TokenGenerator +import grails.plugin.springsecurity.rest.token.storage.jwt.JwtTokenStorageService + +@Slf4j +@CompileStatic +abstract class AbstractJwtTokenGenerator implements TokenGenerator { + + static String REFRESH_ONLY_CLAIM = 'refresh_only' + + Integer defaultExpiration + Integer defaultRefreshExpiration + + JwtTokenStorageService jwtTokenStorageService + + List customClaimProviders + + @Override + AccessToken generateAccessToken(UserDetails details) { + log.debug "Generating an access token with default expiration: ${this.defaultExpiration}" + generateAccessToken(details, this.defaultExpiration) + } + + @Override + AccessToken generateAccessToken(UserDetails details, Integer expiration) { + generateAccessToken(details, true, expiration) + } + + AccessToken generateAccessToken(UserDetails details, boolean withRefreshToken, Integer expiration = this.defaultExpiration, Integer refreshExpiration = this.defaultRefreshExpiration) { + log.debug 'Serializing the principal received' + String serializedPrincipal = serializePrincipal(details) + + JWTClaimsSet.Builder builder = generateClaims(details, serializedPrincipal, expiration) + + log.debug 'Generating access token...' + JWT accessTokenJwt = generateAccessToken(builder.build()) + String accessToken = accessTokenJwt.serialize() + + JWT refreshTokenJwt = null + String refreshToken = null + if (withRefreshToken) { + log.debug 'Generating refresh token...' + refreshTokenJwt = generateRefreshToken(details, serializedPrincipal, refreshExpiration) + refreshToken = refreshTokenJwt.serialize() + } + + return new AccessToken(details, details.authorities, accessToken, refreshToken, expiration, accessTokenJwt, refreshTokenJwt) + } + + @CompileDynamic + JWTClaimsSet.Builder generateClaims(UserDetails details, String serializedPrincipal, Integer expiration) { + JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() + builder.subject(details.username) + + Date now = new Date() + builder.issueTime(now) + + if (expiration) { + log.debug "Setting expiration to ${expiration}" + use(TimeCategory) { + builder.expirationTime(now + expiration.seconds) + } + } + + builder.claim('roles', details.authorities?.collect { it.authority }) + builder.claim('principal', serializedPrincipal) + + customClaimProviders.each { CustomClaimProvider customClaimProvider -> + customClaimProvider.provideCustomClaims(builder, details, serializedPrincipal, expiration) + } + + log.debug 'Generated claim set: {}', builder.build().toJSONObject().toString() + return builder + } + + protected String serializePrincipal(UserDetails principal) { + try { + return JwtService.serialize(principal) + } catch (exception) { + log.debug(exception.message) + log.debug "The principal class (${principal.class}) is not serializable. Object: ${principal}" + return null + } + } + + protected abstract JWT generateAccessToken(JWTClaimsSet claimsSet) + + protected JWT generateRefreshToken(UserDetails principal, String serializedPrincipal, Integer expiration) { + JWTClaimsSet.Builder builder = generateClaims(principal, serializedPrincipal, expiration) + + // Flag this token as a refresh token + builder.claim(REFRESH_ONLY_CLAIM, true) + + return generateAccessToken(builder.build()) + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/CustomClaimProvider.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/CustomClaimProvider.groovy new file mode 100644 index 00000000000..eb694f9d739 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/CustomClaimProvider.groovy @@ -0,0 +1,40 @@ +/* + * 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.rest.token.generation.jwt + +import com.nimbusds.jwt.JWTClaimsSet + +import org.springframework.security.core.userdetails.UserDetails + +/** + * Interface providing a hook to have a chance to add additional claims inside the JWT + */ +interface CustomClaimProvider { + + /** + * The method will be called when the JWT is built. Use the builder to include additional claims, if needed. + * + * @param builder the claim builder used to add additional claims + * @param details the {@link UserDetails} representing the authenticated user + * @param principal the principal, usually the username + * @param expiration the expiration time in seconds for which the JWT is configured to + */ + void provideCustomClaims(JWTClaimsSet.Builder builder, UserDetails details, String principal, Integer expiration) + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/DefaultRSAKeyProvider.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/DefaultRSAKeyProvider.groovy new file mode 100644 index 00000000000..b0990eb9702 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/DefaultRSAKeyProvider.groovy @@ -0,0 +1,70 @@ +/* + * 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.rest.token.generation.jwt + +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.RSAPrivateKeySpec +import java.security.spec.RSAPublicKeySpec + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +/** + * Generates a key pair on the fly. Should be used only for testing purposes, but other than that, a proper + * {@link FileRSAKeyProvider} should be used. + */ +@Slf4j +@CompileStatic +class DefaultRSAKeyProvider implements RSAKeyProvider { + + RSAPublicKey publicKey + + RSAPrivateKey privateKey + + DefaultRSAKeyProvider() { + log.warn '*' * 80 + log.warn '* WARNING: you are using the default RSA key provider, which generates a pair *' + log.warn '* of public/private keys every time the application runs. This means that *' + log.warn "* generated tokens won't be decrypted across executions. *" + log.warn '* *' + log.warn '* Please generate your own keys using SSL and switch to a FileRSAKeyProvider. *' + log.warn '*' * 80 + + // create an instance of KeyPairGenerator suitable for generating RSA keys + // and initialise it with the bit length of the modulus required + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance('RSA') + keyPairGenerator.initialize(2048) + + // generate the key pair + KeyPair keyPair = keyPairGenerator.genKeyPair() + + // create KeyFactory and RSA Keys Specs + KeyFactory keyFactory = KeyFactory.getInstance('RSA') + RSAPublicKeySpec publicKeySpec = keyFactory.getKeySpec(keyPair.public, RSAPublicKeySpec) + RSAPrivateKeySpec privateKeySpec = keyFactory.getKeySpec(keyPair.private, RSAPrivateKeySpec) + + // generate (and retrieve) RSA Keys from the KeyFactory using Keys Specs + publicKey = keyFactory.generatePublic(publicKeySpec) as RSAPublicKey + privateKey = keyFactory.generatePrivate(privateKeySpec) as RSAPrivateKey + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/EncryptedJwtTokenGenerator.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/EncryptedJwtTokenGenerator.groovy new file mode 100644 index 00000000000..bfcb869b578 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/EncryptedJwtTokenGenerator.groovy @@ -0,0 +1,61 @@ +/* + * 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.rest.token.generation.jwt + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.crypto.RSAEncrypter +import com.nimbusds.jwt.EncryptedJWT +import com.nimbusds.jwt.JWT +import com.nimbusds.jwt.JWTClaimsSet + +/** + * Generates RSA-encrypted JWT's + */ +@Slf4j +@CompileStatic +class EncryptedJwtTokenGenerator extends AbstractJwtTokenGenerator { + + RSAKeyProvider keyProvider + + JWEAlgorithm jweAlgorithm + + EncryptionMethod encryptionMethod + + @Override + protected JWT generateAccessToken(JWTClaimsSet claimsSet) { + JWEHeader header = new JWEHeader(jweAlgorithm, encryptionMethod) + + // Create the encrypted JWT object + EncryptedJWT jwt = new EncryptedJWT(header, claimsSet) + + // Create an encrypter with the specified public RSA key + RSAEncrypter encrypter = new RSAEncrypter(keyProvider.publicKey) + + // Do the actual encryption + jwt.encrypt(encrypter) + + return jwt + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/FileRSAKeyProvider.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/FileRSAKeyProvider.groovy new file mode 100644 index 00000000000..51d170bc696 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/FileRSAKeyProvider.groovy @@ -0,0 +1,65 @@ +/* + * 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.rest.token.generation.jwt + +import java.security.KeyFactory +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.springframework.beans.factory.InitializingBean + +/** + * Loads RSA public/private key's from files + */ +@Slf4j +@CompileStatic +class FileRSAKeyProvider implements RSAKeyProvider, InitializingBean { + + /** Full path to the public key so that {@code new File(publicKeyPath).exists() == true} */ + String publicKeyPath + + /** Full path to the private key so that {@code new File(publicKeyPath).exists() == true} */ + String privateKeyPath + + RSAPublicKey publicKey + RSAPrivateKey privateKey + + @Override + void afterPropertiesSet() throws Exception { + log.debug 'Loading public/private key from DER files' + KeyFactory kf = KeyFactory.getInstance('RSA') + + def key = new File(publicKeyPath) + log.debug "Public key path: ${key.absolutePath}" + def keyBytes = key.bytes + def spec = new X509EncodedKeySpec(keyBytes) + publicKey = kf.generatePublic(spec) as RSAPublicKey + + key = new File(privateKeyPath) + log.debug "Private key path: ${key.absolutePath}" + keyBytes = key.bytes + spec = new PKCS8EncodedKeySpec(keyBytes) + privateKey = kf.generatePrivate(spec) as RSAPrivateKey + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/IssuerClaimProvider.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/IssuerClaimProvider.groovy new file mode 100644 index 00000000000..82182202e4c --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/IssuerClaimProvider.groovy @@ -0,0 +1,37 @@ +/* + * 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.rest.token.generation.jwt + +import com.nimbusds.jwt.JWTClaimsSet + +import org.springframework.security.core.userdetails.UserDetails + +/** + * Sets the issuer of the JWT as per a configuration value + */ +class IssuerClaimProvider implements CustomClaimProvider { + + String issuerName + + @Override + void provideCustomClaims(JWTClaimsSet.Builder builder, UserDetails details, String principal, Integer expiration) { + builder.issuer(issuerName) + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/RSAKeyProvider.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/RSAKeyProvider.groovy new file mode 100644 index 00000000000..c78870cd418 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/RSAKeyProvider.groovy @@ -0,0 +1,33 @@ +/* + * 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.rest.token.generation.jwt + +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey + +/** + * Implementations of this interface must take care of providing a pair of RSA private/public keys + */ +interface RSAKeyProvider { + + RSAPublicKey getPublicKey() + + RSAPrivateKey getPrivateKey() + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/SignedJwtTokenGenerator.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/SignedJwtTokenGenerator.groovy new file mode 100644 index 00000000000..dcf0a3cd0c3 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/generation/jwt/SignedJwtTokenGenerator.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 grails.plugin.springsecurity.rest.token.generation.jwt + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jwt.JWT +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT + +import org.springframework.beans.factory.InitializingBean + +/** + * Generates JWT's protected using HMAC with SHA-256 + */ +@Slf4j +@CompileStatic +class SignedJwtTokenGenerator extends AbstractJwtTokenGenerator implements InitializingBean { + + String jwtSecret + + JWSSigner signer + + JWSAlgorithm jwsAlgorithm + + @Override + void afterPropertiesSet() throws Exception { + signer = new MACSigner(jwtSecret) + } + + @Override + protected JWT generateAccessToken(JWTClaimsSet claimsSet) { + SignedJWT signedJWT = new SignedJWT(new JWSHeader(jwsAlgorithm), claimsSet) + signedJWT.sign(signer) + + return signedJWT + } + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/reader/HttpHeaderTokenReader.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/reader/HttpHeaderTokenReader.groovy new file mode 100644 index 00000000000..b1489a499d9 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/reader/HttpHeaderTokenReader.groovy @@ -0,0 +1,44 @@ +/* + * 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.rest.token.reader + +import groovy.transform.CompileStatic + +import jakarta.servlet.http.HttpServletRequest + +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * Reads the token from a configurable HTTP Header + */ + +@CompileStatic +class HttpHeaderTokenReader implements TokenReader { + + String headerName + + /** + * @return the token from the header {@link #headerName}, null otherwise + */ + @Override + AccessToken findToken(HttpServletRequest request) { + String tokenValue = request.getHeader(headerName) + return tokenValue ? new AccessToken(tokenValue) : null + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/reader/TokenReader.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/reader/TokenReader.groovy new file mode 100644 index 00000000000..ad58e1d4389 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/reader/TokenReader.groovy @@ -0,0 +1,36 @@ +/* + * 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.rest.token.reader + +import jakarta.servlet.http.HttpServletRequest + +import grails.plugin.springsecurity.rest.token.AccessToken + +public interface TokenReader { + + /** + * Reads a token (if any) from the request + * + * @param request the HTTP request + * @param response the response, in case any status code has to be sent + * @return the token when found, null otherwise + */ + AccessToken findToken(HttpServletRequest request) + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/rendering/AccessTokenJsonRenderer.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/rendering/AccessTokenJsonRenderer.groovy new file mode 100644 index 00000000000..2a48ddc81d7 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/rendering/AccessTokenJsonRenderer.groovy @@ -0,0 +1,30 @@ +/* + * 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.rest.token.rendering + +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * Generates a JSON representation of a {@link org.springframework.security.core.userdetails.UserDetails} object. + */ +public interface AccessTokenJsonRenderer { + + String generateJson(AccessToken accessToken) + +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/rendering/DefaultAccessTokenJsonRenderer.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/rendering/DefaultAccessTokenJsonRenderer.groovy new file mode 100644 index 00000000000..0e7928b5f80 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/rendering/DefaultAccessTokenJsonRenderer.groovy @@ -0,0 +1,86 @@ +/* + * 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.rest.token.rendering + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import org.pac4j.core.profile.CommonProfile + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.util.Assert + +import grails.converters.JSON +import grails.plugin.springsecurity.rest.oauth.OauthUser +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * Generates a JSON response like the following: {"username":"john.doe","roles":["USER","ADMIN"],"access_token":"1a2b3c4d"}. + * If the principal is an instance of {@link grails.plugin.springsecurity.rest.oauth.OauthUser}, also "email" ({@link CommonProfile#getEmail()}) and + * "displayName" ({@link CommonProfile#getDisplayName()}) will be rendered + */ +@Slf4j +@CompileStatic +class DefaultAccessTokenJsonRenderer implements AccessTokenJsonRenderer { + + String usernamePropertyName + String tokenPropertyName + String authoritiesPropertyName + + Boolean useBearerToken + + String generateJson(AccessToken accessToken) { + Assert.isInstanceOf(UserDetails, accessToken.principal, 'A UserDetails implementation is required') + UserDetails userDetails = accessToken.principal as UserDetails + + Map result = [ + (usernamePropertyName): userDetails.username, + (authoritiesPropertyName): accessToken.authorities.collect { GrantedAuthority role -> role.authority } + ] + + if (useBearerToken) { + result.token_type = 'Bearer' + result.access_token = accessToken.accessToken + + if (accessToken.expiration) { + result.expires_in = accessToken.expiration + } + + if (accessToken.refreshToken) result.refresh_token = accessToken.refreshToken + + } else { + result["$tokenPropertyName".toString()] = accessToken.accessToken + } + + if (userDetails instanceof OauthUser) { + CommonProfile profile = (userDetails as OauthUser).userProfile + if (profile) { + result.email = profile.email + result.displayName = profile.displayName + } + } + + JSON jsonResult = result as JSON + + log.debug "Generated JSON:\n${jsonResult.toString(true)}" + + return jsonResult.toString() + } +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/TokenNotFoundException.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/TokenNotFoundException.groovy new file mode 100644 index 00000000000..f138cdd93de --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/TokenNotFoundException.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 grails.plugin.springsecurity.rest.token.storage + +import groovy.transform.InheritConstructors + +import org.springframework.security.core.AuthenticationException + +/** + * Thrown if the desired token is not found by the {@link TokenStorageService} + */ +@InheritConstructors +class TokenNotFoundException extends AuthenticationException {} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/TokenStorageService.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/TokenStorageService.groovy new file mode 100644 index 00000000000..1cb8899ca9b --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/TokenStorageService.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.rest.token.storage + +import org.springframework.security.core.Authentication +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.AccessToken + +/** + * Implementations of this trait are responsible to load user information from a token storage system, and to store + * token information into it. + */ +trait TokenStorageService { + + /** + * Returns a principal object given the passed token value + * @throws TokenNotFoundException if no token is found in the storage + */ + abstract UserDetails loadUserByToken(String tokenValue) throws TokenNotFoundException + + /** + * Stores a token. It receives the principal to store any additional information together with the token, + * like the username associated. + * + * @see Authentication#getPrincipal() + */ + void storeToken(String tokenValue, UserDetails principal) {} + + /** + * Stores the access token. Allows for handling of refresh token and other JWT claims as needed. + */ + void storeToken(AccessToken accessToken) { + storeToken(accessToken.accessToken, accessToken.principal) + } + + /** + * Removes a token from the storage. + * @throws TokenNotFoundException if the given token is not found in the storage + */ + abstract void removeToken(String tokenValue) throws TokenNotFoundException +} diff --git a/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/jwt/JwtTokenStorageService.groovy b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/jwt/JwtTokenStorageService.groovy new file mode 100644 index 00000000000..9bba0381792 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/jwt/JwtTokenStorageService.groovy @@ -0,0 +1,133 @@ +/* + * 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.rest.token.storage.jwt + +import java.text.ParseException + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import com.nimbusds.jose.JOSEException +import com.nimbusds.jwt.JWT + +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService + +import grails.plugin.springsecurity.rest.JwtService +import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugin.springsecurity.rest.token.storage.TokenStorageService + +/** + * Re-hydrates JWT's with HMAC protection or JWE encryption + */ +@Slf4j +@CompileStatic +class JwtTokenStorageService implements TokenStorageService { + + JwtService jwtService + UserDetailsService userDetailsService + + @Override + UserDetails loadUserByToken(String tokenValue) throws TokenNotFoundException { + Date now = new Date() + try { + JWT jwt = jwtService.parse(tokenValue) + + if (jwt.JWTClaimsSet.expirationTime?.before(now)) { + throw new TokenNotFoundException("Token ${tokenValue} has expired") + } + + boolean isRefresh = jwt.JWTClaimsSet.getBooleanClaim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM) || jwt.JWTClaimsSet.expirationTime == null + if (isRefresh) { + return loadUserFromRefreshToken(jwt) + } + + return loadUserFromAccessToken(jwt) + + } catch (ParseException ignored) { + throw new TokenNotFoundException("Token ${tokenValue} is not valid") + } catch (JOSEException ignored) { + throw new TokenNotFoundException("Token ${tokenValue} has an invalid signature") + } + } + + /** + * Load user details for an access token + */ + protected UserDetails loadUserFromAccessToken(JWT jwt) { + log.debug 'Verified JWT, trying to deserialize the principal object' + try { + UserDetails details = JwtService.deserialize(jwt.JWTClaimsSet.getStringClaim('principal')) + log.debug 'UserDetails deserialized: {}', details + if (details) { + return details + } + } catch (exception) { + log.debug(exception.message) + } + + log.debug 'Returning a org.springframework.security.core.userdetails.User instance' + + List roles = jwt.JWTClaimsSet.getStringArrayClaim('roles')?.collect { String role -> new SimpleGrantedAuthority(role) } + return new User(jwt.JWTClaimsSet.subject, 'N/A', roles) + } + + /** + * Load user details for a refresh token + * + * @param jwt the refresh token + * @return + */ + protected UserDetails loadUserFromRefreshToken(JWT jwt) { + UserDetails principal = userDetailsService.loadUserByUsername(jwt.JWTClaimsSet.subject) + + if (!principal) { + throw new TokenNotFoundException('Token no longer valid, principal not found') + } + if (!principal.enabled) { + throw new TokenNotFoundException('Token no longer valid, account disabled') + } + if (!principal.accountNonExpired) { + throw new TokenNotFoundException('Token no longer valid, account expired') + } + if (!principal.accountNonLocked) { + throw new TokenNotFoundException('Token no longer valid, account locked') + } + if (!principal.credentialsNonExpired) { + throw new TokenNotFoundException('Token no longer valid, credentials expired') + } + + return principal + } + + @Override + void storeToken(String tokenValue, UserDetails principal) { + log.debug 'Nothing to store as this is a stateless implementation' + } + + @Override + void removeToken(String tokenValue) throws TokenNotFoundException { + log.debug 'Nothing to remove as this is a stateless implementation' + throw new TokenNotFoundException("Token ${tokenValue} cannot be removed as this is a stateless implementation") + } + +} diff --git a/grails-spring-security/rest/core/src/main/resources/DefaultRestSecurityConfig.groovy b/grails-spring-security/rest/core/src/main/resources/DefaultRestSecurityConfig.groovy new file mode 100644 index 00000000000..411e14a34c8 --- /dev/null +++ b/grails-spring-security/rest/core/src/main/resources/DefaultRestSecurityConfig.groovy @@ -0,0 +1,85 @@ +/* + * 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. + */ +import jakarta.servlet.http.HttpServletResponse + +security { + + rest { + + active = true + + login { + active = true + endpointUrl = '/api/login' + usernamePropertyName = 'username' + passwordPropertyName = 'password' + failureStatusCode = HttpServletResponse.SC_UNAUTHORIZED //401 + useJsonCredentials = true + useRequestParamsCredentials = false + } + + logout { + endpointUrl = '/api/logout' + } + + token { + + generation { + useSecureRandom = true + useUUID = false + + jwt { + issuer = "Spring Security REST Grails Plugin" + + algorithm = 'HS256' + + jweAlgorithm = 'RSA-OAEP' + encryptionMethod = 'A128GCM' + } + + } + + storage { + jwt { + useSignedJwt = true + useEncryptedJwt = false + + secret = null + expiration = 3600 + refreshExpiration = null + } + } + + validation { + active = true + headerName = 'X-Auth-Token' + endpointUrl = '/api/validate' + tokenHeaderMissingStatusCode = HttpServletResponse.SC_UNAUTHORIZED //401 + enableAnonymousAccess = false + useBearerToken = true + } + + rendering { + usernamePropertyName = 'username' + tokenPropertyName = 'access_token' + authoritiesPropertyName = 'roles' + } + } + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/BearerTokenAuthenticationFailureHandlerSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/BearerTokenAuthenticationFailureHandlerSpec.groovy new file mode 100644 index 00000000000..52d56189681 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/BearerTokenAuthenticationFailureHandlerSpec.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.rest + +import spock.lang.Specification +import spock.lang.Subject + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException + +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.bearer.BearerTokenAuthenticationFailureHandler +import grails.plugin.springsecurity.rest.token.bearer.BearerTokenReader +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException + +@Subject(BearerTokenAuthenticationFailureHandler) +class BearerTokenAuthenticationFailureHandlerSpec extends Specification { + + def handler = new BearerTokenAuthenticationFailureHandler() + def mockBearerTokenReader = Mock(BearerTokenReader) + + def setup() { + handler.tokenReader = mockBearerTokenReader + } + + def "when bad credentials credentials are sent, it responds 401"() { + given: + def request = new MockHttpServletRequest() + def response = new MockHttpServletResponse() + + when: + def exception = new AuthenticationCredentialsNotFoundException('No credentials :-(') + handler.onAuthenticationFailure(request, response, exception) + + then: + response.status == 401 + response.getHeader('WWW-Authenticate') == 'Bearer' + } + + def "it will send a 401 status and WWW-Authenticate header with an error param when credentials are invalid"() { + given: + def request = new MockHttpServletRequest() + def response = new MockHttpServletResponse() + + when: + def exception = new TokenNotFoundException('Bad token :-(') + handler.onAuthenticationFailure(request, response, exception) + + then: + 1 * mockBearerTokenReader.findToken(request) >> new AccessToken("wrongToken") + response.status == 401 + response.getHeader('WWW-Authenticate') == 'Bearer error="invalid_token"' + } + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/JwtServiceSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/JwtServiceSpec.groovy new file mode 100644 index 00000000000..4523ced94a3 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/JwtServiceSpec.groovy @@ -0,0 +1,118 @@ +/* + * 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.rest + +import com.nimbusds.jose.JOSEException +import com.nimbusds.jwt.JWT +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.PlainJWT +import spock.lang.Ignore +import spock.lang.Specification + +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.jwt.EncryptedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.generation.jwt.RSAKeyProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.SignedJwtTokenGenerator +import grails.testing.services.ServiceUnitTest + +class JwtServiceSpec extends Specification implements TokenGeneratorSupport, ServiceUnitTest { + + void "it can serialize and deserialize compressed objects"() { + given: + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + when: + String serialized = JwtService.serialize(userDetails) + UserDetails deserialized = JwtService.deserialize(serialized) + + then: + userDetails == deserialized + } + + @Ignore + void "performance test of JWT parsing"() { + given: + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + SignedJwtTokenGenerator signedJwtTokenGenerator = getTokenGenerator(false) as SignedJwtTokenGenerator + AccessToken signedAccessToken = signedJwtTokenGenerator.generateAccessToken(userDetails) + + EncryptedJwtTokenGenerator encryptedJwtTokenGenerator = getTokenGenerator(true) as EncryptedJwtTokenGenerator + AccessToken encryptedAccessToken = encryptedJwtTokenGenerator.generateAccessToken(userDetails) + + when: + JWT jwt + def timings = [:] + + timings.SignedJwt = timeMillis { + jwt = signedJwtTokenGenerator.jwtTokenStorageService.jwtService + .parse(signedAccessToken.accessToken) + } + + timings.EncryptedJwt = timeMillis { + jwt = encryptedJwtTokenGenerator.jwtTokenStorageService.jwtService + .parse(encryptedAccessToken.accessToken) + } + + println "SignedJwt: ${timings.SignedJwt} ms" + println "EncryptedJwt: ${timings.EncryptedJwt} ms" + println "Done" + + then: + jwt + + } + + private static long timeMillis(Closure block) { + long start = System.nanoTime() + block.call() + return (System.nanoTime() - start) / 1_000_000 + } + + void "denying unsigned JWT when not expected"() { + given: + PlainJWT jwt = new PlainJWT(new JWTClaimsSet.Builder().build()) + service.jwtSecret = "mysecret" + + when: + service.parse(jwt.serialize()) + + then: + JOSEException exception = thrown() + exception.message == 'Unsigned/unencrypted JWT not expected' + } + + void "denying unencrypted JWT when not expected"() { + given: + PlainJWT jwt = new PlainJWT(new JWTClaimsSet.Builder().build()) + service.keyProvider = Mock(RSAKeyProvider) + + when: + service.parse(jwt.serialize()) + + then: + JOSEException exception = thrown() + exception.message == 'Unsigned/unencrypted JWT not expected' + } + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFailureHandlerSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFailureHandlerSpec.groovy new file mode 100644 index 00000000000..6b8dfe899b6 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestAuthenticationFailureHandlerSpec.groovy @@ -0,0 +1,51 @@ +/* + * 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.rest + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse + +import spock.lang.Specification + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.AuthenticationException + +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException + +class RestAuthenticationFailureHandlerSpec extends Specification { + + void "it returns #statusCode when authentication fails"() { + given: + RestAuthenticationFailureHandler handler = new RestAuthenticationFailureHandler() + HttpServletRequest request = new MockHttpServletRequest() + HttpServletResponse response = new MockHttpServletResponse() + AuthenticationException exception = new TokenNotFoundException('n/a') + + when: + handler.statusCode = statusCode + handler.onAuthenticationFailure(request, response, exception) + + then: + response.status == statusCode + + where: + statusCode << [401, 403] + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestAuthenticationProviderSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestAuthenticationProviderSpec.groovy new file mode 100644 index 00000000000..b444d9c05eb --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestAuthenticationProviderSpec.groovy @@ -0,0 +1,79 @@ +/* + * 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.rest + +import spock.lang.Issue +import spock.lang.Specification + +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.provisioning.InMemoryUserDetailsManager + +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.jwt.SignedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugin.springsecurity.rest.token.storage.jwt.JwtTokenStorageService + +class RestAuthenticationProviderSpec extends Specification implements TokenGeneratorSupport { + + RestAuthenticationProvider restAuthenticationProvider + SignedJwtTokenGenerator tokenGenerator + + + void setup() { + this.tokenGenerator = setupSignedJwtTokenGenerator() + this.restAuthenticationProvider = new RestAuthenticationProvider(useJwt: true) + UserDetailsService userDetailsService = new InMemoryUserDetailsManager([]) + UserDetails testUser = new User('testUser', 'testPassword', []) + userDetailsService.createUser(testUser) + + JwtService jwtService = new JwtService(jwtSecret: this.tokenGenerator.jwtTokenStorageService.jwtService.jwtSecret) + this.restAuthenticationProvider.jwtService = jwtService + this.restAuthenticationProvider.tokenStorageService = new JwtTokenStorageService(jwtService: jwtService, userDetailsService: userDetailsService) + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/276") + void "if the JWT's expiration time is null, it's validated successfully"() { + given: + AccessToken accessToken = tokenGenerator.generateAccessToken(new User('testUser', 'testPassword', []), 0) + + when: + Authentication result = this.restAuthenticationProvider.authenticate(accessToken) + + then: + result.authenticated + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/391") + void "refresh tokens should not be usable for authentication"() { + given: + AccessToken accessToken = tokenGenerator.generateAccessToken(new User('testUser', 'testPassword', []), 0) + accessToken.accessToken = accessToken.refreshToken + + when: + this.restAuthenticationProvider.authenticate(accessToken) + + then: + def e = thrown(TokenNotFoundException) + e.message =~ /Token .* is not valid/ + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestOauthControllerSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestOauthControllerSpec.groovy new file mode 100644 index 00000000000..bc3c6dcdbdb --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestOauthControllerSpec.groovy @@ -0,0 +1,210 @@ +/* + * 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.rest + +import groovy.transform.InheritConstructors + +import com.nimbusds.jwt.JWT +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.PlainJWT +import spock.lang.Issue +import spock.lang.Specification + +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UsernameNotFoundException + +import grails.plugin.springsecurity.rest.authentication.RestAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.error.DefaultCallbackErrorHandler +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.rendering.AccessTokenJsonRenderer +import grails.plugin.springsecurity.rest.token.storage.TokenStorageService +import grails.testing.web.controllers.ControllerUnitTest + +import static org.springframework.http.HttpStatus.* + +@Issue("https://github.com/grails/grails-spring-security-rest/issues/237") +class RestOauthControllerSpec extends Specification implements ControllerUnitTest { + + /** + * The frontend callback URL stored in config.groovy + */ + private frontendCallbackBaseUrl = "http://config.com/welcome#token=" + + def setup() { + grailsApplication.config.merge(['grails.plugin.springsecurity.rest.oauth.frontendCallbackUrl': { String tokenValue -> + frontendCallbackBaseUrl + tokenValue + }]) + + params.provider = "google" + } + + private Exception injectDependencies(Exception caughtException) { + def stubRestOauthService = Stub(RestOauthService) + stubRestOauthService.storeAuthentication(*_) >> { throw caughtException } + controller.restOauthService = stubRestOauthService + controller.callbackErrorHandler = new DefaultCallbackErrorHandler() + caughtException + } + + private String getExpectedUrl(Exception exception, expectedErrorValue, String baseUrl = frontendCallbackBaseUrl) { + def message = exception.message + "${baseUrl}&error=${expectedErrorValue}&message=${message}&error_description=${message}&error_code=${exception.class.simpleName}" + } + + def 'UsernameNotFoundException caught within callback action with frontend closure URL in Config.groovy'() { + + def caughtException = injectDependencies(new UsernameNotFoundException('message')) + + when: + controller.callback() + + then: + String expectedUrl = getExpectedUrl(caughtException, FORBIDDEN.value()) + response.redirectedUrl == expectedUrl + } + + def 'UsernameNotFoundException caught within callback action with frontend string URL in Config.groovy'() { + + def caughtException = injectDependencies(new UsernameNotFoundException('message')) + grailsApplication.config.merge(['grails.plugin.springsecurity.rest.oauth.frontendCallbackUrl': frontendCallbackBaseUrl]) + + when: + controller.callback() + + then: + String expectedUrl = getExpectedUrl(caughtException, FORBIDDEN.value()) + response.redirectedUrl == expectedUrl + } + + def 'UsernameNotFoundException caught within callback action with frontend URL in session'() { + + def caughtException = injectDependencies(new UsernameNotFoundException('message')) + def frontendCallbackBaseUrlSession = "http://session.com/welcome#token=" + request.session[controller.CALLBACK_ATTR] = frontendCallbackBaseUrlSession + + when: + controller.callback() + + then: + String expectedUrl = getExpectedUrl(caughtException, FORBIDDEN.value(), frontendCallbackBaseUrlSession) + response.redirectedUrl == expectedUrl + } + + def 'Non-UsernameNotFoundException with cause that has code caught within callback action'() { + + def caughtException = injectDependencies(new ExceptionWithCodedCause('message')) + + when: + controller.callback() + + then: + String expectedUrl = getExpectedUrl(caughtException, 'cause.code') + response.redirectedUrl == expectedUrl + } + + def 'Non-UsernameNotFoundException without cause caught within callback action'() { + + def caughtException = injectDependencies(new Exception('message')) + + when: + controller.callback() + + then: + String expectedUrl = getExpectedUrl(caughtException, INTERNAL_SERVER_ERROR.value()) + response.redirectedUrl == expectedUrl + } + + void "invalid jwt receives forbidden"() { + given: + TokenStorageService stubbedTokenStorageService = Stub(TokenStorageService) + stubbedTokenStorageService.loadUserByToken(_) >> new User('foo', '', []) + controller.tokenStorageService = stubbedTokenStorageService + + def stubbedTokenGenerator = Stub(AbstractJwtTokenGenerator) { + generateAccessToken() >> { u, b -> new AccessToken('accessToken') } + } + controller.tokenGenerator = stubbedTokenGenerator + + controller.authenticationEventPublisher = Mock(RestAuthenticationEventPublisher) + controller.jwtService = Mock(JwtService) + + when: + params.grant_type = 'refresh_token' + params.refresh_token = 'refresh_token_1' + request.method = 'POST' + controller.accessToken() + + then: + 1 * controller.jwtService.parse('refresh_token_1') >> { token -> + null + } + 0 * controller.authenticationEventPublisher.publishTokenCreation(_) + response.status == FORBIDDEN.value() + } + + void "it publishes RestTokenCreationEvent's"() { + given: + TokenStorageService stubbedTokenStorageService = Stub(TokenStorageService) + stubbedTokenStorageService.loadUserByToken(_) >> new User('foo', '', []) + controller.tokenStorageService = stubbedTokenStorageService + + def stubbedTokenGenerator = Stub(AbstractJwtTokenGenerator) { + generateAccessToken() >> { u, b -> new AccessToken('accessToken') } + } + controller.tokenGenerator = stubbedTokenGenerator + + controller.authenticationEventPublisher = Mock(RestAuthenticationEventPublisher) + controller.jwtService = Mock(JwtService) + controller.accessTokenJsonRenderer = Mock(AccessTokenJsonRenderer) + + JWT returnedToken = new PlainJWT(new JWTClaimsSet.Builder().claim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM, true).build()) + + when: + params.grant_type = 'refresh_token' + params.refresh_token = 'refresh_token_1' + request.method = 'POST' + controller.accessToken() + + then: + 1 * controller.jwtService.parse('refresh_token_1') >> { token -> + returnedToken + } + 1 * controller.authenticationEventPublisher.publishTokenCreation(_) + 1 * controller.accessTokenJsonRenderer.generateJson(_) >> { token -> + assert !token.is(returnedToken) + 'rendered json' + } + response.status == OK.value() + response.text == "rendered json" + } +} + +@InheritConstructors +class ExceptionWithCodedCause extends Exception { + + String getCode() { + 'cause.code' + } + + @Override + synchronized Throwable getCause() { + return this + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestOauthServiceSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestOauthServiceSpec.groovy new file mode 100644 index 00000000000..dad66255394 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestOauthServiceSpec.groovy @@ -0,0 +1,88 @@ +/* + * 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.rest + +import org.pac4j.oauth.client.* +import spock.lang.Specification +import spock.lang.Unroll + +import grails.testing.services.ServiceUnitTest +import grails.web.mapping.LinkGenerator + +/** + * Created by Svante on 2014-10-15. + */ +class RestOauthServiceSpec extends Specification implements ServiceUnitTest { + + def setup() { + def grailsLinkGenerator = Mock(LinkGenerator) + 1 * grailsLinkGenerator.link(_ as Map) >> "callbackUrl" + service.grailsLinkGenerator = grailsLinkGenerator + } + + def "it can create a client for CasOAuthWrapper"() { + + given: + def provider = "cas" + grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.client"] = CasOAuthWrapperClient + grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.key"] = 'my_key' + grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.secret"] = 'my_secret' + grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.casOAuthUrl"] = 'cas_oauth_url' + + when: + def client = service.getClient(provider) + + then: + assert client instanceof CasOAuthWrapperClient + assert client.key == grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.key"] + assert client.secret == grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.secret"] + assert client.casOAuthUrl == grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.casOAuthUrl"] + + } + + @Unroll + def "it can create a client for #provider"() { + given: + grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.client"] = clientClass + grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.key"] = 'my_key' + grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.secret"] = 'my_secret' + + when: + def client = service.getClient(provider) + + then: + client.class == clientClass + client.key == grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.key"] + client.secret == grailsApplication.config["grails.plugin.springsecurity.rest.oauth.${provider}.secret"] + + where: + provider | clientClass + 'dropbox' | DropBoxClient + 'foursquare' | FoursquareClient + 'linkedin' | LinkedIn2Client + 'paypal' | PayPalClient + 'twitter' | TwitterClient + 'vk' | VkClient + 'windowslive' | WindowsLiveClient + 'wordpress' | WordPressClient + 'yahoo' | YahooClient + } + + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestSecurityEventListenerSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestSecurityEventListenerSpec.groovy new file mode 100644 index 00000000000..0f27575c9f8 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestSecurityEventListenerSpec.groovy @@ -0,0 +1,122 @@ +/* + * 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.rest + +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Subject + +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationEvent +import org.springframework.context.support.GenericApplicationContext +import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent + +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.rest.token.AccessToken + +@Subject(RestSecurityEventListener) +class RestSecurityEventListenerSpec extends Specification { + + @Shared + RestSecurityEventListener listener + + @Shared + AccessToken accessToken = new AccessToken("test") + + void setConfig(String event, Closure closure) { + ConfigObject co = new ConfigObject() + co.put(event, closure) + SpringSecurityUtils.securityConfig = co + } + + void setup() { + listener = new RestSecurityEventListener() + } + + void "test closure gets executed for onRestTokenCreationEvent"() { + setup: + ApplicationEvent passedEvent + ApplicationContext passedContext + setConfig("onRestTokenCreationEvent") { ApplicationEvent e, ApplicationContext c -> + passedEvent = e + passedContext = c + } + + when: + listener.setApplicationContext(new GenericApplicationContext()) + listener.onApplicationEvent(new RestTokenCreationEvent(accessToken)) + + then: + passedEvent instanceof RestTokenCreationEvent + passedContext instanceof GenericApplicationContext + + } + + void "test closure gets executed for onRestTokenCreationEvent with no context"() { + setup: + ApplicationEvent passedEvent + ApplicationContext passedContext + setConfig("onRestTokenCreationEvent") { ApplicationEvent e, ApplicationContext c -> + passedEvent = e + passedContext = c + } + + when: + listener.onApplicationEvent(new RestTokenCreationEvent(accessToken)) + + then: + passedEvent instanceof RestTokenCreationEvent + passedContext == null + } + + void "test closure gets executed for a standard spring security event"() { + setup: + ApplicationEvent passedEvent + ApplicationContext passedContext + setConfig("onInteractiveAuthenticationSuccessEvent") { ApplicationEvent e, ApplicationContext c -> + passedEvent = e + passedContext = c + } + + when: + listener.setApplicationContext(new GenericApplicationContext()) + listener.onApplicationEvent(new InteractiveAuthenticationSuccessEvent(accessToken, RestSecurityEventListenerSpec.class)) + + then: + passedEvent instanceof InteractiveAuthenticationSuccessEvent + passedContext instanceof GenericApplicationContext + } + + void "test closure gets executed for a standard spring security event with no context"() { + setup: + ApplicationEvent passedEvent + ApplicationContext passedContext + setConfig("onInteractiveAuthenticationSuccessEvent") { ApplicationEvent e, ApplicationContext c -> + passedEvent = e + passedContext = c + } + + when: + listener.onApplicationEvent(new InteractiveAuthenticationSuccessEvent(accessToken, RestSecurityEventListenerSpec.class)) + + then: + passedEvent instanceof InteractiveAuthenticationSuccessEvent + passedContext == null + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestTokenReaderSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestTokenReaderSpec.groovy new file mode 100644 index 00000000000..630f3e26dff --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestTokenReaderSpec.groovy @@ -0,0 +1,54 @@ +/* + * 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.rest + +import spock.lang.Specification +import spock.lang.Subject +import spock.lang.Unroll + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +import grails.plugin.springsecurity.rest.token.reader.HttpHeaderTokenReader + +@Subject(HttpHeaderTokenReader) +class RestTokenReaderSpec extends Specification { + + def tokenReader = new HttpHeaderTokenReader() + def request = new MockHttpServletRequest() + def response = new MockHttpServletResponse() + + + @Unroll + def "token value can be read from a custom header in a #method request"() { + def token = 'mytokenvalue' + def header = 'mycustomheader' + + tokenReader.headerName = header + request.addHeader(header, token) + request.method = method + + expect: + tokenReader.findToken(request).accessToken == token + + where: + method << ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] + } + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestTokenValidationFilterUnitSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestTokenValidationFilterUnitSpec.groovy new file mode 100644 index 00000000000..caa1a9a4dc3 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/RestTokenValidationFilterUnitSpec.groovy @@ -0,0 +1,116 @@ +/* + * 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.rest + +import jakarta.servlet.FilterChain + +import spock.lang.Specification +import spock.lang.Subject + +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.userdetails.User +import org.springframework.security.web.authentication.AuthenticationFailureHandler +import org.springframework.security.web.authentication.AuthenticationSuccessHandler + +import grails.plugin.springsecurity.rest.authentication.RestAuthenticationEventPublisher +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.reader.TokenReader +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import org.grails.plugins.testing.GrailsMockHttpServletRequest + +@Subject(RestTokenValidationFilter) +class RestTokenValidationFilterUnitSpec extends Specification { + + def filter = new RestTokenValidationFilter(active: true) + + def request = new GrailsMockHttpServletRequest() + def response = new MockHttpServletResponse() + def chain = new MockFilterChain() + + def setup() { + filter.authenticationSuccessHandler = Mock(AuthenticationSuccessHandler) + filter.authenticationFailureHandler = Mock(AuthenticationFailureHandler) + filter.tokenReader = Mock(TokenReader) + filter.authenticationEventPublisher = Mock(RestAuthenticationEventPublisher) + filter.requestMatcher = Stub(SpringSecurityRestFilterRequestMatcher) { + matches(_) >> true + } + } + + void "authentication passes when a valid token is found"() { + given: + String token = 'mytokenvalue' + filter.restAuthenticationProvider = new StubRestAuthenticationProvider( + validToken: token, + username: 'user', + password: 'password' + ) + + + when: + filter.doFilter(request, response, chain) + + then: + response.status == 200 + 1 * filter.tokenReader.findToken(request) >> new AccessToken(token) + 0 * filter.authenticationFailureHandler.onAuthenticationFailure(_, _, _) + 1 * filter.authenticationEventPublisher.publishAuthenticationSuccess(_ as Authentication) + notThrown(TokenNotFoundException) + } + + void "when a token cannot be found, the request continues through the filter chain"() { + given: + filter.restAuthenticationProvider = new StubRestAuthenticationProvider( + validToken: 'mytokenvalue', + username: 'user', + password: 'password' + ) + FilterChain filterChain = GroovyMock(MockFilterChain, global: true) + + when: + filter.doFilter(request, response, chain) + + then: + 1 * filter.tokenReader.findToken(request) >> null + 1 * filterChain.doFilter(request, response) + } +} + +/** + * Stubs out the RestAuthenticationProvider so that we can specify what a valid token is, + * and have it return as if it authentication correctly if the token matches. + */ +class StubRestAuthenticationProvider extends RestAuthenticationProvider { + + String validToken + String username + String password + + Authentication authenticate(Authentication authentication) throws AuthenticationException { + authentication = authentication as AccessToken + if (authentication.accessToken == validToken) { + return new AccessToken(new User(username, password, []), null, validToken) + } + + throw new TokenNotFoundException('Token not found') + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestFilterRequestMatcherSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestFilterRequestMatcherSpec.groovy new file mode 100644 index 00000000000..50cce3038a4 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestFilterRequestMatcherSpec.groovy @@ -0,0 +1,51 @@ +/* + * 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.rest + +import spock.lang.Specification +import spock.lang.Unroll + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockServletContext + +class SpringSecurityRestFilterRequestMatcherSpec extends Specification { + + @Unroll + void "if the context path is #contextPath, the URI #uri matches: #matches"(String contextPath, String uri, boolean matches) { + given: + SpringSecurityRestFilterRequestMatcher requestMatcher = new SpringSecurityRestFilterRequestMatcher("/api/login") + MockServletContext servletContext = new MockServletContext() + servletContext.setContextPath(contextPath) + MockHttpServletRequest request = new MockHttpServletRequest(servletContext, "POST", uri) + + when: + boolean matchesResult = requestMatcher.matches(request) + + then: + matchesResult == matches + + where: + contextPath | uri || matches + "/" | "/api/login" || true + "/" | "/api/loginxxx" || false + "/foo" | "/api/login/foo" || false + } + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/TokenGeneratorSupport.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/TokenGeneratorSupport.groovy new file mode 100644 index 00000000000..81bb5edd427 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/TokenGeneratorSupport.groovy @@ -0,0 +1,47 @@ +/* + * 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.rest + +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.crypto.MACSigner + +import grails.plugin.springsecurity.rest.token.generation.jwt.DefaultRSAKeyProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.EncryptedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.generation.jwt.RSAKeyProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.SignedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.jwt.JwtTokenStorageService + +trait TokenGeneratorSupport { + + SignedJwtTokenGenerator setupSignedJwtTokenGenerator() { + String secret = 'foobar' * 10 + def jwtTokenStorageService = new JwtTokenStorageService(jwtService: new JwtService(jwtSecret: secret)) + return new SignedJwtTokenGenerator(defaultExpiration: 3600, jwtSecret: secret, signer: new MACSigner(secret), jwtTokenStorageService: jwtTokenStorageService, customClaimProviders: [], jwsAlgorithm: JWSAlgorithm.HS256) + } + + EncryptedJwtTokenGenerator setupEncryptedJwtTokenGenerator() { + RSAKeyProvider keyProvider = new DefaultRSAKeyProvider() + def jwtTokenStorageService = new JwtTokenStorageService(jwtService: new JwtService(keyProvider: keyProvider)) + return new EncryptedJwtTokenGenerator(defaultExpiration: 3600, jwtTokenStorageService: jwtTokenStorageService, keyProvider: keyProvider, customClaimProviders: [], jweAlgorithm: JWEAlgorithm.RSA_OAEP, encryptionMethod: EncryptionMethod.A128GCM) + } + +} \ No newline at end of file diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy new file mode 100644 index 00000000000..343679a9689 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/authentication/DefaultRestAuthenticationEventPublisherSpec.groovy @@ -0,0 +1,94 @@ +/* + * 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.rest.authentication + +import grails.plugin.springsecurity.rest.RestTokenCreationEvent +import grails.plugin.springsecurity.rest.token.AccessToken +import org.springframework.context.ApplicationEventPublisher +import org.springframework.security.authentication.event.AuthenticationSuccessEvent +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Subject + +@Subject(DefaultRestAuthenticationEventPublisher) +class DefaultRestAuthenticationEventPublisherSpec extends Specification { + + @Shared + ApplicationEventPublisher eventPublisher + + @Shared + AccessToken accessToken = new AccessToken("test") + + void setup() { + eventPublisher = Mock(ApplicationEventPublisher) + } + + void "should execute publishEvent when publisher is set in constructor"() { + when: + new DefaultRestAuthenticationEventPublisher(eventPublisher).publishTokenCreation(accessToken) + + then: + 1 * eventPublisher.publishEvent(_ as RestTokenCreationEvent) + } + + void "should execute publishEvent when publisher is set with setter"() { + when: + DefaultRestAuthenticationEventPublisher publisher = new DefaultRestAuthenticationEventPublisher() + publisher.setApplicationEventPublisher(eventPublisher) + publisher.publishTokenCreation(accessToken) + + then: + 1 * eventPublisher.publishEvent(_ as RestTokenCreationEvent) + } + + void "should execute parent publishEvent when publisher is set in constructor"() { + when: + new DefaultRestAuthenticationEventPublisher(eventPublisher).publishAuthenticationSuccess(accessToken) + + then: + 1 * eventPublisher.publishEvent(_ as AuthenticationSuccessEvent) + } + + void "should execute parent publishEvent when publisher is set with setter"() { + when: + DefaultRestAuthenticationEventPublisher publisher = new DefaultRestAuthenticationEventPublisher() + publisher.setApplicationEventPublisher(eventPublisher) + publisher.publishAuthenticationSuccess(accessToken) + + then: + 1 * eventPublisher.publishEvent(_ as AuthenticationSuccessEvent) + } + + void "should not execute publishEvent when publisher is not set"() { + when: + new DefaultRestAuthenticationEventPublisher().publishTokenCreation(accessToken) + + then: + 0 * eventPublisher.publishEvent(_) + } + + void "should not execute parent publishEvent when publisher is not set"() { + when: + new DefaultRestAuthenticationEventPublisher().publishAuthenticationSuccess(accessToken) + + then: + noExceptionThrown() + 0 * eventPublisher.publishEvent(_) + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/credentials/CredentialsExtractorTestSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/credentials/CredentialsExtractorTestSpec.groovy new file mode 100644 index 00000000000..2c5b37e2e98 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/credentials/CredentialsExtractorTestSpec.groovy @@ -0,0 +1,112 @@ +/* + * 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.rest.credentials + +import spock.lang.Specification + +import grails.core.DefaultGrailsApplication +import grails.plugin.springsecurity.ReflectionUtils +import grails.plugin.springsecurity.SpringSecurityUtils +import org.grails.config.PropertySourcesConfig +import org.grails.plugins.testing.GrailsMockHttpServletRequest +import org.grails.spring.GrailsApplicationContext + +/** + * Specification of all the credentials extractors + */ +class CredentialsExtractorTestSpec extends Specification { + + def config + + def setup() { + def application = new DefaultGrailsApplication() + application.mainContext = new GrailsApplicationContext() + config = new PropertySourcesConfig() + application.getConfig() >> config + ReflectionUtils.application = SpringSecurityUtils.application = application + } + + void "credentials can be extracted from a JSON request"() { + given: + def request = new GrailsMockHttpServletRequest() + request.json = '{"username": "foo", "password": "bar"}' + def extractor = new DefaultJsonPayloadCredentialsExtractor(usernamePropertyName: 'username', passwordPropertyName: 'password') + + and: "Spring security configuration" + SpringSecurityUtils.loadSecondaryConfig 'DefaultRestSecurityConfig' + + when: + def token = extractor.extractCredentials(request) + + then: + token.principal == 'foo' + token.credentials == 'bar' + + } + + void "JSON parsing handles unexpected requests"() { + + given: + def request = new GrailsMockHttpServletRequest() + request.json = '{"different": "format", "of": "JSON"}' + def extractor = new DefaultJsonPayloadCredentialsExtractor(usernamePropertyName: 'username', passwordPropertyName: 'password') + + when: + def token = extractor.extractCredentials(request) + + then: + !token.principal + !token.credentials + + + } + + void "credentials can be extracted from a request params-based request"() { + + given: + def request = new GrailsMockHttpServletRequest() + request.parameters = [username: 'foo', password: 'bar'] + def extractor = new RequestParamsCredentialsExtractor(usernamePropertyName: 'username', passwordPropertyName: 'password') + + when: + def token = extractor.extractCredentials(request) + + then: + token.principal == 'foo' + token.credentials == 'bar' + + } + + void "credentials can be extracted from a JSON request with custom configuration"() { + + given: + def request = new GrailsMockHttpServletRequest() + request.json = '{"login": "foo", "pwd": "bar"}' + def extractor = new DefaultJsonPayloadCredentialsExtractor(usernamePropertyName: 'login', passwordPropertyName: 'pwd') + + when: + def token = extractor.extractCredentials(request) + + then: + token.principal == 'foo' + token.credentials == 'bar' + + } + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/oauth/DefaultOauthUserDetailsServiceSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/oauth/DefaultOauthUserDetailsServiceSpec.groovy new file mode 100644 index 00000000000..59a0313825a --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/oauth/DefaultOauthUserDetailsServiceSpec.groovy @@ -0,0 +1,116 @@ +/* + * 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.rest.oauth + +import org.pac4j.core.profile.CommonProfile +import spock.lang.Shared +import spock.lang.Specification + +import org.springframework.security.authentication.AccountStatusException +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.provisioning.InMemoryUserDetailsManager + +import grails.core.DefaultGrailsApplication +import grails.plugin.springsecurity.ReflectionUtils +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.userdetails.DefaultPreAuthenticationChecks +import org.grails.spring.GrailsApplicationContext + +class DefaultOauthUserDetailsServiceSpec extends Specification { + + @Shared + def application + + def setupSpec() { + application = new DefaultGrailsApplication() + application.mainContext = new GrailsApplicationContext() + ReflectionUtils.application = SpringSecurityUtils.application = application + } + + def setup() { + SpringSecurityUtils.resetSecurityConfig() + } + + void "it load users either existing or not"() { + given: + application.config.merge(['grails.plugin.springsecurity.userLookup.userDomainClassName': 'demo.User']) + + OauthUserDetailsService service = new DefaultOauthUserDetailsService() + service.preAuthenticationChecks = new DefaultPreAuthenticationChecks() + service.userDetailsService = new InMemoryUserDetailsManager([]) + UserDetails jimi = new User('jimi', 'jimispassword', [new SimpleGrantedAuthority('ROLE_USER'), new SimpleGrantedAuthority('ROLE_ADMIN')]) + service.userDetailsService.createUser(jimi) + + + when: + OauthUser oauthUser = service.loadUserByUserProfile([id: username] as CommonProfile, []) + + then: + oauthUser.authorities.size() == authorities + + where: + username | authorities + 'test' | 0 + 'jimi' | 2 + } + + void "when a user exists but it's disabled, it throws an exception"() { + given: + application.config.merge(['grails.plugin.springsecurity.userLookup.userDomainClassName': 'demo.User']) + + OauthUserDetailsService service = new DefaultOauthUserDetailsService() + service.preAuthenticationChecks = new DefaultPreAuthenticationChecks() + service.userDetailsService = new InMemoryUserDetailsManager([]) + UserDetails jimi = new User('jimi', 'jimispassword', false, false, false, false, [new SimpleGrantedAuthority('ROLE_USER'), new SimpleGrantedAuthority('ROLE_ADMIN')]) + service.userDetailsService.createUser(jimi) + + when: + OauthUser oauthUser = service.loadUserByUserProfile([id: 'jimi'] as CommonProfile, []) + + then: + thrown(AccountStatusException) + } + + void "if userLookUp.userDomainClassName not set, don't look for the user with userDetailService; return basic OauthUser instead"() { + given: + application.config.merge(['grails.plugin.springsecurity.userLookup.userDomainClassName': null]) + + OauthUserDetailsService service = new DefaultOauthUserDetailsService() + Collection expectedRoles = [new MockGrantedAuthority(authority: 'ROLE_USER')] + + when: + OauthUser oauthUser = service.loadUserByUserProfile([id: 'jimi'] as CommonProfile, expectedRoles) + + then: + oauthUser + oauthUser.username == 'jimi' + oauthUser.authorities + oauthUser.authorities.size() == expectedRoles.size() + oauthUser.authorities.first() == expectedRoles.first() + } + +} + +class MockGrantedAuthority implements GrantedAuthority { + + String authority +} \ No newline at end of file diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/rfc6750/BearerTokenReaderSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/rfc6750/BearerTokenReaderSpec.groovy new file mode 100644 index 00000000000..97366d37323 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/rfc6750/BearerTokenReaderSpec.groovy @@ -0,0 +1,158 @@ +/* + * 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.rest.rfc6750 + +import spock.lang.Issue +import spock.lang.Specification +import spock.lang.Unroll + +import org.springframework.http.MediaType +import org.springframework.mock.web.MockHttpServletResponse + +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.bearer.BearerTokenReader +import org.grails.plugins.testing.GrailsMockHttpServletRequest + +/** + * Created by ajbrown on 6/25/14. + */ +class BearerTokenReaderSpec extends Specification { + + def tokenReader = new BearerTokenReader() + def request = new GrailsMockHttpServletRequest() + def response = new MockHttpServletResponse() + + def setup() { + //TODO, untill we get spring-test 4.0.5 or greater, there is no getParts() on MockHttpServletRequest. We + // just need to define it so that an exception doesn't occur when checking for parts. + request.metaClass.getParts = { -> [] } + } + + @Unroll + def "token value can be read from #method request Authorization header (prefixed with 'Bearer ')"() { + given: + def token = 'mytestotkenvalue' + request.addHeader('Authorization', 'Bearer ' + token) + request.method = method + request.contentType = MediaType.TEXT_PLAIN_VALUE + + expect: + tokenReader.findToken(request) == new AccessToken(token) + + where: + method << ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] + } + + @Unroll + def "token value cannot be read from #method request access_token query string parameter, due to its insecurity"() { + given: + def token = 'mytesttokenvalue' + request.queryString = 'access_token=' + token + request.contentType = MediaType.TEXT_PLAIN_VALUE + + expect: + tokenReader.findToken(request) == null + + where: + method << ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] + } + + @Unroll + def "token value can be read from application/x-www-form-url-encoded #method request body"() { + given: + def token = 'mytesttokenvalue' + request.contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE + request.addParameter('access_token', token) + request.method = method + + expect: + tokenReader.findToken(request) == new AccessToken(token) + + where: + method << ['POST', 'PUT', 'PATCH'] + } + + @Unroll + def "token value will not be read from #method request Authorization header not prefix with 'Bearer'"() { + given: + def token = 'abadtokenvalue' + request.addHeader('Authorization', token) + request.method = method + request.contentType = MediaType.TEXT_PLAIN_VALUE + + expect: + !tokenReader.findToken(request) + + where: + + method << ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] + } + + @Unroll + def "token value will not be read from #method request body if not form encoded"() { + given: + def token = 'abadtokenvalue' + request.addParameter('access_token', token) + request.contentType = MediaType.MULTIPART_FORM_DATA_VALUE + request.method = method + + expect: + !tokenReader.findToken(request) + + where: + method << ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] + } + + @Unroll + def "token value will not be read from request body if request method is #method"() { + given: + def token = 'mytesttokenvalue' + request.contentType = MediaType.APPLICATION_FORM_URLENCODED_VALUE + request.addParameter('access_token', token) + request.method = method + + expect: + !tokenReader.findToken(request) + + where: + method << ['GET'] + } + + @Unroll + def "when the mediatype is null still works"() { + given: + request.contentType = null + + expect: + !tokenReader.findToken(request) + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/235") + def "it doesn't crash if token is missing"() { + given: + request.addHeader('Authorization', 'Bearer') + + when: + tokenReader.findToken(request) + + then: + notThrown(Throwable) + } + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/TokenGeneratorSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/TokenGeneratorSpec.groovy new file mode 100644 index 00000000000..844b8d436a1 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/TokenGeneratorSpec.groovy @@ -0,0 +1,57 @@ +/* + * 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.rest.token + +import org.apache.commons.lang3.StringUtils +import spock.lang.Specification +import spock.lang.Unroll + +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User + +import grails.plugin.springsecurity.rest.token.generation.SecureRandomTokenGenerator +import grails.plugin.springsecurity.rest.token.generation.UUIDTokenGenerator + +class TokenGeneratorSpec extends Specification { + + @Unroll + void "#generator.class.name generates tokens with 32 characters"() { + + given: + def tokens = [] + def userDetails = new User('foo', 'bar', [new SimpleGrantedAuthority('USER')]) + + when: + def startTime = System.nanoTime() + 10000.times { + tokens << generator.generateAccessToken(userDetails) + } + def endTime = System.nanoTime() + + println "Time elapsed: ${(endTime - startTime)}" + + then: + tokens.every { AccessToken token -> + token.accessToken.size() == 32 && StringUtils.isAlphanumeric(token.accessToken) + } + + where: + generator << [new SecureRandomTokenGenerator(), new UUIDTokenGenerator()] + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAccessDeniedHandlerSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAccessDeniedHandlerSpec.groovy new file mode 100644 index 00000000000..649c58cdea6 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/bearer/BearerTokenAccessDeniedHandlerSpec.groovy @@ -0,0 +1,43 @@ +/* + * 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.rest.token.bearer + +import spock.lang.Specification + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.security.access.AccessDeniedException + +/** + * Created by Sean Brady on 12/3/14. + */ +class BearerTokenAccessDeniedHandlerSpec extends Specification { + + def bearerTokenAccessDeniedHandler = new BearerTokenAccessDeniedHandler() + + def "sets the correct bearer token header when forbidden"() { + given: + def response = new MockHttpServletResponse() + def request = new MockHttpServletRequest() + when: + bearerTokenAccessDeniedHandler.handle(request, response, new AccessDeniedException("fake")) + then: + response.getHeader('WWW-Authenticate') == 'Bearer error="insufficient_scope"' + } +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/generation/JwtTokenGeneratorSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/generation/JwtTokenGeneratorSpec.groovy new file mode 100644 index 00000000000..5397c9a6cfa --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/generation/JwtTokenGeneratorSpec.groovy @@ -0,0 +1,245 @@ +/* + * 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.rest.token.generation + +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.crypto.RSADecrypter +import com.nimbusds.jwt.EncryptedJWT +import com.nimbusds.jwt.JWT +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.JWTParser +import spock.lang.Issue +import spock.lang.Specification +import spock.lang.Unroll + +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.JwtService +import grails.plugin.springsecurity.rest.TokenGeneratorSupport +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.generation.jwt.CustomClaimProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.DefaultRSAKeyProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.EncryptedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.generation.jwt.IssuerClaimProvider +import grails.plugin.springsecurity.rest.token.generation.jwt.SignedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.jwt.JwtTokenStorageService +import grails.spring.BeanBuilder + +class JwtTokenGeneratorSpec extends Specification implements TokenGeneratorSupport { + + @Unroll + void "#jwtTokenGenerator.class.simpleName generates access tokens with refresh tokens that can be rehydrated back"() { + given: + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + when: + AccessToken accessToken = jwtTokenGenerator.generateAccessToken(userDetails) + + then: + accessToken.accessToken + accessToken.refreshToken + + when: + UserDetails parsedUserDetails = jwtTokenGenerator.jwtTokenStorageService.loadUserByToken(accessToken.accessToken) + + then: + parsedUserDetails == userDetails + + where: + jwtTokenGenerator << [setupSignedJwtTokenGenerator(), setupEncryptedJwtTokenGenerator()] + + } + + @Unroll + void "refresh tokens generated by #jwtTokenGenerator.class.simpleName doesn't expire by default"() { + given: + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + when: + AccessToken accessToken = jwtTokenGenerator.generateAccessToken(userDetails) + JWT accessTokenJwt = JWTParser.parse(accessToken.accessToken) + JWT refreshTokenJwt = JWTParser.parse(accessToken.refreshToken) + [accessTokenJwt, refreshTokenJwt].each { JWT jwt -> + if (jwt instanceof EncryptedJWT) { + EncryptedJWT encryptedJWT = jwt as EncryptedJWT + RSADecrypter decrypter = new RSADecrypter((jwtTokenGenerator as EncryptedJwtTokenGenerator).keyProvider.privateKey) + encryptedJWT.decrypt(decrypter) + } + } + + then: + accessTokenJwt.JWTClaimsSet.expirationTime + !refreshTokenJwt.JWTClaimsSet.expirationTime + + where: + jwtTokenGenerator << [setupSignedJwtTokenGenerator(), setupEncryptedJwtTokenGenerator()] + } + + @Unroll + void "refresh tokens generated by #jwtTokenGenerator.class.simpleName can be configured to expire"() { + given: + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + when: + AccessToken accessToken = jwtTokenGenerator.generateAccessToken(userDetails, true, 3600, 7200) + JWT accessTokenJwt = JWTParser.parse(accessToken.accessToken) + JWT refreshTokenJwt = JWTParser.parse(accessToken.refreshToken) + [accessTokenJwt, refreshTokenJwt].each { JWT jwt -> + if (jwt instanceof EncryptedJWT) { + EncryptedJWT encryptedJWT = jwt as EncryptedJWT + RSADecrypter decrypter = new RSADecrypter((jwtTokenGenerator as EncryptedJwtTokenGenerator).keyProvider.privateKey) + encryptedJWT.decrypt(decrypter) + } + } + + then: + accessTokenJwt.JWTClaimsSet.expirationTime + refreshTokenJwt.JWTClaimsSet.expirationTime + accessTokenJwt.JWTClaimsSet.expirationTime != refreshTokenJwt.JWTClaimsSet.expirationTime + + where: + jwtTokenGenerator << [setupSignedJwtTokenGenerator(), setupEncryptedJwtTokenGenerator()] + } + + @Unroll + void "refresh tokens generated by #jwtTokenGenerator.class.simpleName have an identifying claim"() { + given: + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + when: + AccessToken accessToken = jwtTokenGenerator.generateAccessToken(userDetails) + JWT accessTokenJwt = JWTParser.parse(accessToken.accessToken) + JWT refreshTokenJwt = JWTParser.parse(accessToken.refreshToken) + [accessTokenJwt, refreshTokenJwt].each { JWT jwt -> + if (jwt instanceof EncryptedJWT) { + EncryptedJWT encryptedJWT = jwt as EncryptedJWT + RSADecrypter decrypter = new RSADecrypter((jwtTokenGenerator as EncryptedJwtTokenGenerator).keyProvider.privateKey) + encryptedJWT.decrypt(decrypter) + } + } + + then: "refresh token has custom claim" + refreshTokenJwt.JWTClaimsSet.getBooleanClaim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM) + + and: "access token does not" + !accessTokenJwt.JWTClaimsSet.getBooleanClaim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM) + + where: + jwtTokenGenerator << [setupSignedJwtTokenGenerator(), setupEncryptedJwtTokenGenerator()] + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/295") + void "custom claims can be added"() { + given: + SignedJwtTokenGenerator tokenGenerator = setupSignedJwtTokenGenerator() + CustomClaimProvider claimProvider = [ + provideCustomClaims: { JWTClaimsSet.Builder builder, UserDetails details, String principal, Integer expiration -> + builder.claim("favouriteTeam", "Real Madrid") + } + ] as CustomClaimProvider + tokenGenerator.customClaimProviders << claimProvider + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + when: + AccessToken accessToken = tokenGenerator.generateAccessToken(userDetails) + JWT jwt = tokenGenerator.jwtTokenStorageService.jwtService.parse(accessToken.accessToken) + + then: + jwt.JWTClaimsSet.getClaim('favouriteTeam') == 'Real Madrid' + } + + void "generated access tokens contain the JWT object"() { + given: + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + when: + AccessToken accessToken = jwtTokenGenerator.generateAccessToken(userDetails) + + then: + accessToken.accessTokenJwt + accessToken.accessTokenJwt.serialize() == accessToken.accessToken + accessToken.refreshTokenJwt + accessToken.refreshTokenJwt.serialize() == accessToken.refreshToken + + where: + jwtTokenGenerator << [setupSignedJwtTokenGenerator(), setupEncryptedJwtTokenGenerator()] + } + + void "generated tokens contains the issuer claim"() { + given: + SignedJwtTokenGenerator tokenGenerator = getTokenGenerator(false) as SignedJwtTokenGenerator + UserDetails userDetails = new User('username', 'password', [new SimpleGrantedAuthority('ROLE_USER')]) + + when: + AccessToken accessToken = tokenGenerator.generateAccessToken(userDetails) + JWT jwt = tokenGenerator.jwtTokenStorageService.jwtService.parse(accessToken.accessToken) + + then: + jwt.JWTClaimsSet.issuer == 'Spring Security REST test' + } + + private AbstractJwtTokenGenerator getTokenGenerator(boolean useEncryptedJwt) { + BeanBuilder beanBuilder = new BeanBuilder() + beanBuilder.beans { + keyProvider(DefaultRSAKeyProvider) + + issuerClaimProvider(IssuerClaimProvider) { + issuerName = 'Spring Security REST test' + } + + def customClaimProviderList = [ref('issuerClaimProvider')] + + jwtService(JwtService) { + keyProvider = ref('keyProvider') + jwtSecret = 'foo123' * 8 + } + tokenStorageService(JwtTokenStorageService) { + jwtService = ref('jwtService') + } + + if (useEncryptedJwt) { + tokenGenerator(EncryptedJwtTokenGenerator) { + jwtTokenStorageService = ref('tokenStorageService') + keyProvider = ref('keyProvider') + defaultExpiration = 3600 + customClaimProviders = customClaimProviderList + jweAlgorithm = JWEAlgorithm.RSA_OAEP + encryptionMethod = EncryptionMethod.A128GCM + } + } else { + tokenGenerator(SignedJwtTokenGenerator) { + jwtTokenStorageService = ref('tokenStorageService') + jwtSecret = 'foo123' * 8 + defaultExpiration = 3600 + customClaimProviders = customClaimProviderList + jwsAlgorithm = JWSAlgorithm.HS256 + } + } + } + + return beanBuilder.createApplicationContext().getBean(AbstractJwtTokenGenerator, 'tokenGenerator') + } + + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/rendering/DefaultRestAuthenticationTokenJsonRendererSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/rendering/DefaultRestAuthenticationTokenJsonRendererSpec.groovy new file mode 100644 index 00000000000..f84dbf88e46 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/rendering/DefaultRestAuthenticationTokenJsonRendererSpec.groovy @@ -0,0 +1,151 @@ +/* + * 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.rest.token.rendering + +import org.pac4j.core.profile.CommonProfile +import spock.lang.Issue +import spock.lang.Shared +import spock.lang.Specification +import spock.lang.Unroll + +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User + +import grails.converters.JSON +import grails.core.DefaultGrailsApplication +import grails.plugin.springsecurity.ReflectionUtils +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.rest.RestOauthController +import grails.plugin.springsecurity.rest.oauth.OauthUser +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.testing.web.controllers.ControllerUnitTest +import org.grails.spring.GrailsApplicationContext + +class DefaultRestAuthenticationTokenJsonRendererSpec extends Specification implements ControllerUnitTest { + + @Shared + DefaultAccessTokenJsonRenderer renderer + + def setupSpec() { + def application = new DefaultGrailsApplication() + application.mainContext = new GrailsApplicationContext() + def config = new ConfigObject() + application.getConfig() >> config + ReflectionUtils.application = application + SpringSecurityUtils.loadSecondaryConfig 'DefaultRestSecurityConfig' + + renderer = new DefaultAccessTokenJsonRenderer(authoritiesPropertyName: 'roles', + tokenPropertyName: 'access_token', + usernamePropertyName: 'username') + } + + @Unroll + void "it renders proper JSON for a given token when the user has #roles.size() roles"() { + given: + def username = 'john.doe' + def password = 'donttellanybody' + def tokenValue = '1a2b3c4d' + def userDetails = new User(username, password, roles) + + AccessToken token = new AccessToken(userDetails, roles, tokenValue) + + when: + def jsonResult = renderer.generateJson(token) + + then: + jsonResult == generatedJson + + where: + roles | generatedJson + [new SimpleGrantedAuthority('USER'), new SimpleGrantedAuthority('ADMIN')] | '{"username":"john.doe","roles":["USER","ADMIN"],"access_token":"1a2b3c4d"}' + [] | '{"username":"john.doe","roles":[],"access_token":"1a2b3c4d"}' + } + + void "Render JSON with custom properties"() { + given: + def username = 'john.doe' + def password = 'donttellanybody' + def tokenValue = '1a2b3c4d' + def userDetails = new User(username, password, roles) + + AccessToken token = new AccessToken(userDetails, roles, tokenValue) + + AccessTokenJsonRenderer customRenderer = + new DefaultAccessTokenJsonRenderer(authoritiesPropertyName: 'authorities', + tokenPropertyName: 'token', + usernamePropertyName: 'login') + + when: + def jsonResult = customRenderer.generateJson(token) + + then: + jsonResult == generatedJson + + where: + roles | generatedJson + [new SimpleGrantedAuthority('USER'), new SimpleGrantedAuthority('ADMIN')] | '{"login":"john.doe","authorities":["USER","ADMIN"],"token":"1a2b3c4d"}' + [] | '{"login":"john.doe","authorities":[],"token":"1a2b3c4d"}' + + + } + + @Issue('https://github.com/grails/grails-spring-security-rest/issues/33') + void "it renders OAuth information if the principal is an OAuthUser"() { + given: + def username = 'john.doe' + def password = 'donttellanybody' + def tokenValue = '1a2b3c4d' + def roles = [new SimpleGrantedAuthority('USER'), new SimpleGrantedAuthority('ADMIN')] + + def profile = new CommonProfile() + profile.addAttribute('display_name', "John Doe") + profile.addAttribute('email', "john@doe.com") + + def userDetails = new OauthUser(username, password, roles, profile) + + AccessToken token = new AccessToken(userDetails, roles, tokenValue) + + when: + def jsonResult = renderer.generateJson(token) + + then: + jsonResult == '{"username":"john.doe","roles":["USER","ADMIN"],"access_token":"1a2b3c4d","email":"john@doe.com","displayName":"John Doe"}' + } + + def "it renders valid Bearer tokens"() { + given: + def tokenValue = "abcdefghijklmnopqrstuvwxyz1234567890" + def userDetails = new User('test', 'test', []) + def token = new AccessToken(userDetails, userDetails.authorities, tokenValue) + renderer.useBearerToken = true + + when: + def result = renderer.generateJson(token) + def json = JSON.parse(result) + + then: + result.size() + json.access_token == tokenValue + json.token_type == 'Bearer' + + cleanup: + renderer.useBearerToken = false + } + +} diff --git a/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/storage/jwt/JwtTokenStorageServiceSpec.groovy b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/storage/jwt/JwtTokenStorageServiceSpec.groovy new file mode 100644 index 00000000000..e5011c16488 --- /dev/null +++ b/grails-spring-security/rest/core/src/test/groovy/grails/plugin/springsecurity/rest/token/storage/jwt/JwtTokenStorageServiceSpec.groovy @@ -0,0 +1,95 @@ +/* + * 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.rest.token.storage.jwt + +import com.nimbusds.jose.JWSAlgorithm +import spock.lang.Issue +import spock.lang.Specification + +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService + +import grails.plugin.springsecurity.rest.JwtService +import grails.plugin.springsecurity.rest.token.AccessToken +import grails.plugin.springsecurity.rest.token.generation.jwt.SignedJwtTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.testing.services.ServiceUnitTest + +/** + * Created by @marcos-carceles on 03/03/15. + */ +class JwtTokenStorageServiceSpec extends Specification implements ServiceUnitTest { + + JwtTokenStorageService service + SignedJwtTokenGenerator tokenGenerator + + void setup() { + service = new JwtTokenStorageService(jwtService: new JwtService(jwtSecret: 'fooo' * 8)) + tokenGenerator = new SignedJwtTokenGenerator(jwtSecret: service.jwtService.jwtSecret, defaultExpiration: 60, jwtTokenStorageService: service, customClaimProviders: [], jwsAlgorithm: JWSAlgorithm.HS256) + tokenGenerator.afterPropertiesSet() + } + + def "JWT Token signatures are verified"() { + given: + AccessToken accessToken = tokenGenerator.generateAccessToken(new User('testUser', 'testPassword', [])) + + when: + UserDetails user = service.loadUserByToken(accessToken.accessToken) + + then: + user.username == 'testUser' + + when: + service.loadUserByToken(accessToken.accessToken.replaceAll(/...$/, 'bar')) + + then: + thrown(TokenNotFoundException) + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/391") + def "refresh token with optional expiration can be successfully loaded"() { + given: "an access token that expires" + AccessToken accessToken = tokenGenerator.generateAccessToken(new User('testUser', 'testPassword', []), true, 3600, 3600) + service.userDetailsService = Mock(UserDetailsService) + + when: + UserDetails user = service.loadUserByToken(accessToken.refreshToken) + + then: + user.username == 'testUser' + + and: + 1 * service.userDetailsService.loadUserByUsername('testUser') >> { new User('testUser', 'testPassword', []) } + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/391") + def "refresh token with optional expiration fails when expired"() { + given: + AccessToken accessToken = tokenGenerator.generateAccessToken(new User('testUser', 'testPassword', []), true, 3600, 1) + sleep(1000) // Crude, but effective + + when: + service.loadUserByToken(accessToken.refreshToken) + + then: + def e = thrown(TokenNotFoundException) + e.message =~ /Token .* has expired/ + } +} diff --git a/grails-spring-security/rest/docker-compose.yml b/grails-spring-security/rest/docker-compose.yml new file mode 100644 index 00000000000..e9133cb6b5d --- /dev/null +++ b/grails-spring-security/rest/docker-compose.yml @@ -0,0 +1,23 @@ +# 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. + +memcached: + image: memcached + ports: + - 11211:11211 +redis: + image: redis + ports: + - 6379:6379 \ No newline at end of file diff --git a/grails-spring-security/rest/generate-test-apps.sh b/grails-spring-security/rest/generate-test-apps.sh new file mode 100755 index 00000000000..113c71e6ebc --- /dev/null +++ b/grails-spring-security/rest/generate-test-apps.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# 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. +# + +set -e + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +PREVIOUS_DIR=$(pwd) +cd "${SCRIPT_DIR}" + +rm -rf ./build || true +mkdir ./build +export GRAILS_SECURITY_VERSION=`cat ${SCRIPT_DIR}/../gradle.properties | grep "^projectVersion=" | sed -n 's/^projectVersion=//p'` +export GRAILS_VERSION=`cat ${SCRIPT_DIR}/../gradle.properties | grep "^grailsVersion=" | sed -n 's/^grailsVersion=//p'` + +for project in `find . -maxdepth 1 -type d ! -name . -name 'spring-security-rest*'`; do + cd "${SCRIPT_DIR}/${project}" + echo "Publishing ${project} to maven local" + ../../gradlew clean publishToMavenLocal +done +cd "${SCRIPT_DIR}/../plugin-core/plugin" +echo "Publishing Spring Security Core plugin to maven local" +../../gradlew clean publishToMavenLocal + +echo "Plugin version: ${GRAILS_SECURITY_VERSION}. Grails version for test apps: ${GRAILS_VERSION}" +source "$HOME/.sdkman/bin/sdkman-init.sh" + +[[ -d ~/.sdkman/candidates/grails/${GRAILS_VERSION} ]] || sdk install grails ${GRAILS_VERSION} +sdk use grails ${GRAILS_VERSION} +cd "${SCRIPT_DIR}/build" + +# TODO: only set ~/.m2/repository once fixed in Grails 7.0.8 +export GRAILS_REPO_URL='mavenLocal()' +for feature in `ls "${SCRIPT_DIR}/spring-security-rest-testapp-profile/features/"`; do + grails create-app -profile org.apache.grails.profiles:spring-security-rest-testapp-profile:${GRAILS_SECURITY_VERSION} -features ${feature} ${feature} +done + +cd "$PREVIOUS_DIR" diff --git a/grails-spring-security/rest/gorm/build.gradle b/grails-spring-security/rest/gorm/build.gradle new file mode 100644 index 00000000000..5dbeeb096d1 --- /dev/null +++ b/grails-spring-security/rest/gorm/build.gradle @@ -0,0 +1,67 @@ +/* + * 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. + */ + +plugins { + id 'groovy' + id 'java-library' + id 'project-report' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.gradle.grails-code-style' + id 'org.apache.grails.gradle.grails-plugin' +} + +version = projectVersion +group = 'org.apache.grails' + +ext { + pomTitle = 'Grails Spring Security REST Plugin' + pomDescription = 'Grails plugin to implement token-based, RESTful authentication using Spring Security' + pomDevelopers = [ + alvarosanchez: 'Alvaro Sanchez-Mariscal', + ] +} + +dependencies { + + implementation platform(project(':grails-bom')) + + api 'org.springframework.security:spring-security-core', { + // api: UserDetails, UserDetailsService + } + + implementation project(':grails-spring-security'), { + // impl: SecurityFilterPosition, SpringSecurityUtils + } + implementation project(':grails-spring-security-rest'), { + // impl: SecureRandomTokenGenerator + } + + compileOnly 'org.apache.groovy:groovy' // Provided as this is a Grails plugin + compileOnly project(':grails-core') // Provided as this is a Grails plugin +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/spring-security-test-config.gradle') +} diff --git a/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGormGrailsPlugin.groovy b/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGormGrailsPlugin.groovy new file mode 100644 index 00000000000..4b9de5ff362 --- /dev/null +++ b/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGormGrailsPlugin.groovy @@ -0,0 +1,88 @@ +/* + * 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.rest + +import grails.plugin.springsecurity.SecurityFilterPosition +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.rest.token.generation.SecureRandomTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.GormTokenStorageService +import grails.plugins.Plugin + +class SpringSecurityRestGormGrailsPlugin extends Plugin { + + // the version or versions of Grails the plugin is designed for + String grailsVersion = '7.0.0 > *' + List loadAfter = ['springSecurityRest'] + List pluginExcludes = [ + 'grails-app/views/**' + ] + + String title = 'Spring Security REST Plugin - GORM support' + String author = 'Alvaro Sanchez-Mariscal' + String authorEmail = '' + String description = 'Implements authentication for REST APIs based on Spring Security. It uses a token-based workflow' + + def profiles = ['web'] + + // URL to the plugin's documentation + String documentation = 'https://apache.github.io/grails-spring-security' + + // Extra (optional) plugin metadata + String license = 'APACHE' + def organization = [name: 'Grails', url: 'https://www.grails.org/'] + + def issueManagement = [system: 'GitHub', url: 'https://github.com/apache/grails-spring-security/issues'] + def scm = [url: 'https://github.com/apache/grails-spring-security'] + + Closure doWithSpring() { + { -> + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active || !conf.rest.active) { + return + } + + boolean printStatusMessages = (conf.printStatusMessages instanceof Boolean) ? conf.printStatusMessages : true + + if (printStatusMessages) { + println '\t... with GORM support' + } + + SpringSecurityUtils.loadSecondaryConfig 'DefaultRestGormSecurityConfig' + conf = SpringSecurityUtils.securityConfig + + SpringSecurityUtils.registerFilter 'restLogoutFilter', SecurityFilterPosition.LOGOUT_FILTER.order - 1 + + tokenStorageService(GormTokenStorageService) { + userDetailsService = ref('userDetailsService') + } + + tokenGenerator(SecureRandomTokenGenerator) + } + } + + void doWithApplicationContext() { + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active || !conf.rest.active) { + return + } + + applicationContext.getBean(RestAuthenticationProvider).useJwt = false + } +} diff --git a/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/gorm/Application.groovy b/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/gorm/Application.groovy new file mode 100644 index 00000000000..d096b47b381 --- /dev/null +++ b/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/gorm/Application.groovy @@ -0,0 +1,32 @@ +/* + * 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.rest.gorm + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration +import grails.plugins.metadata.PluginSource + +@PluginSource +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} diff --git a/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GormTokenStorageService.groovy b/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GormTokenStorageService.groovy new file mode 100644 index 00000000000..632685eda96 --- /dev/null +++ b/grails-spring-security/rest/gorm/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GormTokenStorageService.groovy @@ -0,0 +1,117 @@ +/* + * 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.rest.token.storage + +import groovy.util.logging.Slf4j + +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService + +import grails.core.GrailsApplication +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.util.Holders + +/** + * GORM implementation for token storage. It will look for tokens on the DB using a domain class that will contain the + * generated token and the username associated. + * + * Once the username is found, it will delegate to the configured {@link UserDetailsService} for obtaining authorities + * information. + */ +@Slf4j +class GormTokenStorageService implements TokenStorageService { + + GrailsApplication grailsApplication + + UserDetailsService userDetailsService + + public GormTokenStorageService() { + this.grailsApplication = Holders.grailsApplication + } + + UserDetails loadUserByToken(String tokenValue) throws TokenNotFoundException { + log.debug "Finding token ${tokenValue} in GORM" + def conf = SpringSecurityUtils.securityConfig + String usernamePropertyName = conf.rest.token.storage.gorm.usernamePropertyName + def existingToken = findExistingToken(tokenValue) + + if (existingToken) { + def username = existingToken."${usernamePropertyName}" + return userDetailsService.loadUserByUsername(username) + } + + throw new TokenNotFoundException("Token ${tokenValue} not found") + + } + + void storeToken(String tokenValue, UserDetails principal) { + log.debug "Storing principal for token: ${tokenValue}" + log.debug "Principal: ${principal}" + + def conf = SpringSecurityUtils.securityConfig + String tokenClassName = conf.rest.token.storage.gorm.tokenDomainClassName + String tokenValuePropertyName = conf.rest.token.storage.gorm.tokenValuePropertyName + String usernamePropertyName = conf.rest.token.storage.gorm.usernamePropertyName + def dc = grailsApplication.getClassForName(tokenClassName) + + //TODO check at startup, not here + if (!dc) { + throw new IllegalArgumentException("The specified token domain class '$tokenClassName' is not a domain class ") + } + + dc.withTransaction { status -> + def newTokenObject = dc.newInstance((tokenValuePropertyName): tokenValue, (usernamePropertyName): principal.username) + newTokenObject.save() + } + } + + void removeToken(String tokenValue) throws TokenNotFoundException { + log.debug "Removing token ${tokenValue} from GORM" + def conf = SpringSecurityUtils.securityConfig + String tokenClassName = conf.rest.token.storage.gorm.tokenDomainClassName + def existingToken = findExistingToken(tokenValue) + if (existingToken) { + def dc = grailsApplication.getClassForName(tokenClassName) + dc.withTransaction() { + existingToken.delete() + } + } else { + throw new TokenNotFoundException("Token ${tokenValue} not found") + } + + } + + private findExistingToken(String tokenValue) { + log.debug "Searching in GORM for UserDetails of token ${tokenValue}" + def conf = SpringSecurityUtils.securityConfig + String tokenClassName = conf.rest.token.storage.gorm.tokenDomainClassName + String tokenValuePropertyName = conf.rest.token.storage.gorm.tokenValuePropertyName + def dc = grailsApplication.getClassForName(tokenClassName) + + //TODO check at startup, not here + if (!dc) { + throw new IllegalArgumentException("The specified token domain class '$tokenClassName' is not a domain class") + } + + dc.withTransaction() { status -> + return dc.findWhere((tokenValuePropertyName): tokenValue) + } + } + +} diff --git a/grails-spring-security/rest/gorm/src/main/resources/DefaultRestGormSecurityConfig.groovy b/grails-spring-security/rest/gorm/src/main/resources/DefaultRestGormSecurityConfig.groovy new file mode 100644 index 00000000000..bb1a42b0c2f --- /dev/null +++ b/grails-spring-security/rest/gorm/src/main/resources/DefaultRestGormSecurityConfig.groovy @@ -0,0 +1,34 @@ +/* + * 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. + */ +security { + rest { + token { + storage { + useGorm = true + + gorm { + tokenDomainClassName = null + tokenValuePropertyName = 'tokenValue' + usernamePropertyName = 'username' + } + + } + } + } +} diff --git a/grails-spring-security/rest/grailscache/build.gradle b/grails-spring-security/rest/grailscache/build.gradle new file mode 100644 index 00000000000..bea51e6d0dd --- /dev/null +++ b/grails-spring-security/rest/grailscache/build.gradle @@ -0,0 +1,69 @@ +/* + * 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. + */ + +plugins { + id 'groovy' + id 'java-library' + id 'project-report' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.gradle.grails-code-style' + id 'org.apache.grails.gradle.grails-plugin' +} + +version = projectVersion +group = 'org.apache.grails' + +dependencies { + implementation platform(project(':grails-bom')) + + api project(':grails-cache'), { + // api: GrailsCacheManager + } + api 'org.springframework:spring-context', { + // api: Cache + } + api 'org.springframework.security:spring-security-core', { + // api: UserDetails + } + + implementation project(':grails-spring-security'), { + // impl: SecurityFilterPosition, SpringSecurityUtils + } + implementation project(':grails-spring-security-rest'), { + // impl: SecureRandomTokenGenerator + } + implementation 'jakarta.annotation:jakarta.annotation-api', { + // impl: @PostConstruct(runtime) + } + + compileOnly project(':grails-core') // Provided as this is a Grails plugin + compileOnly 'org.apache.groovy:groovy' // Provided as this is a Grails plugin + + testImplementation 'org.spockframework:spock-core' +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/spring-security-test-config.gradle') +} diff --git a/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGrailsCacheGrailsPlugin.groovy b/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGrailsCacheGrailsPlugin.groovy new file mode 100644 index 00000000000..8423d493706 --- /dev/null +++ b/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestGrailsCacheGrailsPlugin.groovy @@ -0,0 +1,90 @@ +/* + * 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.rest + +import grails.plugin.springsecurity.SecurityFilterPosition +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.rest.token.generation.SecureRandomTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.GrailsCacheTokenStorageService +import grails.plugins.Plugin + +class SpringSecurityRestGrailsCacheGrailsPlugin extends Plugin { + + // the version or versions of Grails the plugin is designed for + String grailsVersion = '7.0.0 > *' + List loadAfter = ['springSecurityRest'] + List pluginExcludes = [ + 'grails-app/views/**' + ] + + String title = 'Spring Security REST Plugin - Grails cache support' + String author = 'Alvaro Sanchez-Mariscal' + String authorEmail = '' + String description = 'Implements authentication for REST APIs based on Spring Security. It uses a token-based workflow' + + def profiles = ['web'] + + // URL to the plugin's documentation + String documentation = 'https://apache.github.io/grails-spring-security' + + // Extra (optional) plugin metadata + String license = 'APACHE' + def organization = [name: 'Grails', url: 'https://www.grails.org'] + + def issueManagement = [system: 'GitHub', url: 'https://github.com/apache/grails-spring-security/issues'] + def scm = [url: 'https://github.com/apache/grails-spring-security'] + + Closure doWithSpring() { + { -> + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active || !conf.rest.active) { + return + } + + boolean printStatusMessages = (conf.printStatusMessages instanceof Boolean) ? conf.printStatusMessages : true + + if (printStatusMessages) { + println '\t... with Grails cache support' + } + + SpringSecurityUtils.loadSecondaryConfig 'DefaultRestGrailsCacheSecurityConfig' + conf = SpringSecurityUtils.securityConfig + + SpringSecurityUtils.registerFilter 'restLogoutFilter', SecurityFilterPosition.LOGOUT_FILTER.order - 1 + + tokenStorageService(GrailsCacheTokenStorageService) { + grailsCacheManager = ref('grailsCacheManager') + cacheName = conf.rest.token.storage.grailsCacheName + } + + tokenGenerator(SecureRandomTokenGenerator) + + } + } + + void doWithApplicationContext() { + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active || !conf.rest.active) { + return + } + + applicationContext.getBean(RestAuthenticationProvider).useJwt = false + } +} diff --git a/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/grailscache/Application.groovy b/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/grailscache/Application.groovy new file mode 100644 index 00000000000..8cda7a88480 --- /dev/null +++ b/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/grailscache/Application.groovy @@ -0,0 +1,32 @@ +/* + * 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.rest.grailscache + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration +import grails.plugins.metadata.PluginSource + +@PluginSource +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} diff --git a/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GrailsCacheTokenStorageService.groovy b/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GrailsCacheTokenStorageService.groovy new file mode 100644 index 00000000000..66a41c514b1 --- /dev/null +++ b/grails-spring-security/rest/grailscache/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/GrailsCacheTokenStorageService.groovy @@ -0,0 +1,84 @@ +/* + * 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.rest.token.storage + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import jakarta.annotation.PostConstruct + +import org.springframework.cache.Cache +import org.springframework.security.core.userdetails.UserDetails + +import org.grails.plugin.cache.GrailsCacheManager + +/** + * Uses Grails Cache plugin to store and retrieve tokens. + */ +@Slf4j +@CompileStatic +class GrailsCacheTokenStorageService implements TokenStorageService { + + GrailsCacheManager grailsCacheManager + String cacheName + + Cache cache + + @Override + void storeToken(String tokenValue, UserDetails principal) { + cache.put(tokenValue, principal) + log.debug "Stored principal $principal for token ${tokenValue}" + } + + @Override + UserDetails loadUserByToken(String tokenValue) throws TokenNotFoundException { + def principal = cache.get(tokenValue)?.get() + if (principal) { + log.debug "Got principal $principal for token ${tokenValue}" + return principal as UserDetails + } + def tokenNotFoundMsg = "No principal found for token $tokenValue" + log.debug tokenNotFoundMsg + throw new TokenNotFoundException(tokenNotFoundMsg) + } + + @Override + void removeToken(String tokenValue) throws TokenNotFoundException { + cache.evict(tokenValue) + log.debug "Removed principal for token ${tokenValue}" + } + + @PostConstruct + void init() { + if (!grailsCacheManager) { + throw new IllegalStateException('GrailsCacheManager was not injected. ' + + 'Install cache plugin to use this implementation of TokenStorageService') + } + if (!cacheName) { + throw new IllegalStateException('Cache name for TokenStorageService was not injected. ' + + 'Use grails.plugin.springsecurity.rest.token.storage.grailsCacheName to specify a cache name') + } + cache = grailsCacheManager.getCache(cacheName) + if (!cache) { + throw new IllegalStateException("Could not retrieve a cache for name $cacheName. " + + "Did you specify a cache '$cacheName' in the cache configuration?") + } + log.debug "${this.class.simpleName} initialized successfully" + } +} diff --git a/grails-spring-security/rest/grailscache/src/main/resources/DefaultRestGrailsCacheSecurityConfig.groovy b/grails-spring-security/rest/grailscache/src/main/resources/DefaultRestGrailsCacheSecurityConfig.groovy new file mode 100644 index 00000000000..c54ece6fce3 --- /dev/null +++ b/grails-spring-security/rest/grailscache/src/main/resources/DefaultRestGrailsCacheSecurityConfig.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. + */ +security { + rest { + token { + storage { + useGrailsCache = true + + grailsCacheName = 'defaultTokenCache' + } + } + } +} diff --git a/grails-spring-security/rest/grailscache/src/test/groovy/grails/plugin/springsecurity/rest/token/storage/GrailsCacheTokenStorageServiceSpec.groovy b/grails-spring-security/rest/grailscache/src/test/groovy/grails/plugin/springsecurity/rest/token/storage/GrailsCacheTokenStorageServiceSpec.groovy new file mode 100644 index 00000000000..6785f32bc05 --- /dev/null +++ b/grails-spring-security/rest/grailscache/src/test/groovy/grails/plugin/springsecurity/rest/token/storage/GrailsCacheTokenStorageServiceSpec.groovy @@ -0,0 +1,116 @@ +/* + * 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.rest.token.storage + +import spock.lang.Specification + +import org.springframework.security.core.userdetails.User + +import grails.plugin.cache.GrailsConcurrentMapCacheManager +import org.grails.plugin.cache.GrailsCacheManager + +class GrailsCacheTokenStorageServiceSpec extends Specification { + + private service + + def setup() { + service = new GrailsCacheTokenStorageService(grailsCacheManager: new GrailsConcurrentMapCacheManager(), cacheName: 'tokenStorage') + service.init() + } + + void "store a principal for a given token"() { + given: + def tokenValue = '12345' + def principal = new User('foo', 'bar', []) + + when: + service.storeToken(tokenValue, principal) + + then: + service.cache.get(tokenValue).get() == principal + } + + void "load a principal by a given token"() { + given: + def tokenValue = '12345' + def givenPrincipal = new User('foo', 'bar', []) + service.storeToken(tokenValue, givenPrincipal) + + when: + def loadedPrincipal = service.loadUserByToken(tokenValue) + + then: + loadedPrincipal == givenPrincipal + } + + void "throw token not found exception if cannot load a principal for a given token"() { + when: + service.loadUserByToken('token-does-not-exists') + + then: + thrown(TokenNotFoundException) + } + + void "remove a principal for a given token"() { + given: + def tokenValue = '12345' + def givenPrincipal = new User('foo', 'bar', []) + service.storeToken(tokenValue, givenPrincipal) + + when: + service.removeToken(tokenValue) + + then: + !service.cache.get(tokenValue) + } + + void "throw illegal state exeption if grails cache manager was not injected"() { + given: + service.grailsCacheManager = null + + when: + service.init() + + then: + thrown(IllegalStateException) + } + + void "throw illegal state exeption if cache name was not injected"() { + given: + service.cacheName = null + + when: + service.init() + + then: + thrown(IllegalStateException) + } + + void "throw illegal state exeption if cache was not retrieved"() { + given: + service.grailsCacheManager = Mock(GrailsCacheManager) + 1 * service.grailsCacheManager.getCache(service.cacheName) >> null + + when: + service.init() + + then: + thrown(IllegalStateException) + } +} diff --git a/grails-spring-security/rest/memcached/build.gradle b/grails-spring-security/rest/memcached/build.gradle new file mode 100644 index 00000000000..3fcf582576a --- /dev/null +++ b/grails-spring-security/rest/memcached/build.gradle @@ -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. + */ + +plugins { + id 'groovy' + id 'java-library' + id 'project-report' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.gradle.grails-code-style' + id 'org.apache.grails.gradle.grails-plugin' +} + +version = projectVersion +group = 'org.apache.grails' + +ext { + pomTitle = 'Grails Spring Security REST Plugin' + pomDescription = 'Grails plugin to implement token-based, RESTful authentication using Spring Security' + pomDevelopers = [ + alvarosanchez: 'Alvaro Sanchez-Mariscal', + ] +} + +dependencies { + + implementation platform(project(':grails-bom')) + + api project(':grails-spring-security-rest'), { + // api: TokenNotFoundException, TokenStorageService + // impl: SecureRandomTokenGenerator + } + api "net.spy:spymemcached:$spyMemcachedVersion", { + // api: MemcachedClient, SerializingTranscoder + // impl: CASValue, DefaultHashAlgorithm, MemcachedClientFactoryBean + } + api 'org.springframework.security:spring-security-core', { + // api: UserDetails + } + + implementation project(':grails-spring-security'), { + // impl: SecurityFilterPosition, SpringSecurityUtils + } + + compileOnly 'org.apache.groovy:groovy' // Provided as this is a Grails plugin + compileOnly project(':grails-core') // Provided as this is a Grails plugin +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/spring-security-test-config.gradle') +} diff --git a/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestMemcachedGrailsPlugin.groovy b/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestMemcachedGrailsPlugin.groovy new file mode 100644 index 00000000000..31c5ca41ac4 --- /dev/null +++ b/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestMemcachedGrailsPlugin.groovy @@ -0,0 +1,110 @@ +/* + * 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.rest + +import net.spy.memcached.DefaultHashAlgorithm +import net.spy.memcached.spring.MemcachedClientFactoryBean + +import grails.plugin.springsecurity.SecurityFilterPosition +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.rest.token.generation.SecureRandomTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.memcached.CustomSerializingTranscoder +import grails.plugin.springsecurity.rest.token.storage.memcached.MemcachedTokenStorageService +import grails.plugins.Plugin + +class SpringSecurityRestMemcachedGrailsPlugin extends Plugin { + + // the version or versions of Grails the plugin is designed for + String grailsVersion = '7.0.0 > *' + List loadAfter = ['springSecurityRest'] + List pluginExcludes = [ + 'grails-app/views/**' + ] + + String title = 'Spring Security REST Plugin - Memcached support' + String author = 'Alvaro Sanchez-Mariscal' + String authorEmail = '' + String description = 'Implements authentication for REST APIs based on Spring Security. It uses a token-based workflow' + + def profiles = ['web'] + + // URL to the plugin's documentation + String documentation = 'https://apache.github.io/grails-spring-security' + + // Extra (optional) plugin metadata + String license = 'APACHE' + def organization = [name: 'Grails', url: 'https://www.grails.org'] + + def issueManagement = [system: 'GitHub', url: 'https://github.com/apache/grails-spring-security/issues'] + def scm = [url: 'https://github.com/apache/grails-spring-security-rest'] + + Closure doWithSpring() { + { -> + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active || !conf.rest.active) { + return + } + + boolean printStatusMessages = (conf.printStatusMessages instanceof Boolean) ? conf.printStatusMessages : true + + if (printStatusMessages) { + println '\t... with Memcached support' + } + + SpringSecurityUtils.loadSecondaryConfig 'DefaultRestMemcachedSecurityConfig' + conf = SpringSecurityUtils.securityConfig + + Properties systemProperties = System.properties + systemProperties.put('net.spy.log.LoggerImpl', 'net.spy.memcached.compat.log.SLF4JLogger') + System.setProperties(systemProperties) + + SpringSecurityUtils.registerFilter 'restLogoutFilter', SecurityFilterPosition.LOGOUT_FILTER.order - 1 + + memcachedClient(MemcachedClientFactoryBean) { + servers = conf.rest.token.storage.memcached.hosts + protocol = 'BINARY' + transcoder = new CustomSerializingTranscoder() + opTimeout = 1000 + timeoutExceptionThreshold = 1998 + hashAlg = DefaultHashAlgorithm.KETAMA_HASH + locatorType = 'CONSISTENT' + failureMode = 'Redistribute' + useNagleAlgorithm = false + } + + tokenStorageService(MemcachedTokenStorageService) { + memcachedClient = ref('memcachedClient') + expiration = conf.rest.token.storage.memcached.expiration + } + + tokenGenerator(SecureRandomTokenGenerator) + + } + } + + void doWithApplicationContext() { + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active || !conf.rest.active) { + return + } + + applicationContext.getBean(RestAuthenticationProvider).useJwt = false + } +} diff --git a/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/memcached/Application.groovy b/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/memcached/Application.groovy new file mode 100644 index 00000000000..49e85932764 --- /dev/null +++ b/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/memcached/Application.groovy @@ -0,0 +1,32 @@ +/* + * 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.rest.memcached + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration +import grails.plugins.metadata.PluginSource + +@PluginSource +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} diff --git a/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/memcached/CustomSerializingTranscoder.groovy b/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/memcached/CustomSerializingTranscoder.groovy new file mode 100644 index 00000000000..23c4bbc4fe4 --- /dev/null +++ b/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/memcached/CustomSerializingTranscoder.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.rest.token.storage.memcached + +import groovy.transform.CompileStatic + +import net.spy.memcached.transcoders.SerializingTranscoder + +@CompileStatic +public class CustomSerializingTranscoder extends SerializingTranscoder { + + @Override + protected Object deserialize(byte[] bytes) { + final ClassLoader currentClassLoader = Thread.currentThread().contextClassLoader + ObjectInputStream inputStream + + try { + ByteArrayInputStream bs = new ByteArrayInputStream(bytes) + inputStream = new ObjectInputStream(bs) { + + @Override + protected Class resolveClass(ObjectStreamClass objectStreamClass) throws IOException, ClassNotFoundException { + try { + return currentClassLoader.loadClass(objectStreamClass.name) + } catch (Exception ignored) { + return super.resolveClass(objectStreamClass) + } + } + } + return inputStream.readObject() + } catch (Exception e) { + e.printStackTrace() + throw new RuntimeException(e) + } finally { + closeStream(inputStream) + } + } + + private static void closeStream(Closeable c) { + if (c != null) { + try { + c.close() + } catch (IOException e) { + e.printStackTrace() + } + } + } +} diff --git a/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/memcached/MemcachedTokenStorageService.groovy b/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/memcached/MemcachedTokenStorageService.groovy new file mode 100644 index 00000000000..604aec74066 --- /dev/null +++ b/grails-spring-security/rest/memcached/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/memcached/MemcachedTokenStorageService.groovy @@ -0,0 +1,85 @@ +/* + * 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.rest.token.storage.memcached + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import net.spy.memcached.CASValue +import net.spy.memcached.MemcachedClient + +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugin.springsecurity.rest.token.storage.TokenStorageService + +/** + * Stores and retrieves tokens in a memcached server. This implementation stores the whole {@link UserDetails} object + * in memcached, leveraging it is serializable. + */ +@Slf4j +@CompileStatic +class MemcachedTokenStorageService implements TokenStorageService { + + MemcachedClient memcachedClient + + /** Expiration in seconds */ + Integer expiration = 3600 + + UserDetails loadUserByToken(String tokenValue) throws TokenNotFoundException { + log.debug "Finding token ${tokenValue} in memcached" + def userDetails = findExistingUserDetails(tokenValue) + if (userDetails) { + return userDetails + } else { + throw new TokenNotFoundException("Token ${tokenValue} not found") + } + } + + void storeToken(String tokenValue, UserDetails principal) { + log.debug "Storing principal for token: ${tokenValue} with expiration of ${expiration} seconds" + log.debug "Principal: ${principal}" + + memcachedClient.set tokenValue, expiration, principal + } + + void removeToken(String tokenValue) throws TokenNotFoundException { + log.debug "Removing token ${tokenValue} from memcached" + def userDetails = findExistingUserDetails(tokenValue) + if (userDetails) { + memcachedClient.delete tokenValue + } else { + throw new TokenNotFoundException("Token ${tokenValue} not found") + } + } + + @SuppressWarnings('GroovyVariableNotAssigned') + private UserDetails findExistingUserDetails(String tokenValue) { + log.debug "Searching in Memcached for UserDetails of token ${tokenValue}" + CASValue result = memcachedClient.getAndTouch(tokenValue, expiration) + UserDetails userDetails + if (result) { + userDetails = result.getValue() as UserDetails + log.debug "UserDetails found: ${userDetails}" + } else { + log.debug 'UserDetails not found' + } + return userDetails + } +} diff --git a/grails-spring-security/rest/memcached/src/main/resources/DefaultRestMemcachedSecurityConfig.groovy b/grails-spring-security/rest/memcached/src/main/resources/DefaultRestMemcachedSecurityConfig.groovy new file mode 100644 index 00000000000..63d253fcbf7 --- /dev/null +++ b/grails-spring-security/rest/memcached/src/main/resources/DefaultRestMemcachedSecurityConfig.groovy @@ -0,0 +1,34 @@ +/* + * 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. + */ +security { + rest { + token { + storage { + useMemcached = true + memcached { + hosts = 'localhost:11211' + username = '' + password = '' + + expiration = 3600 + } + } + } + } +} diff --git a/grails-spring-security/rest/redis/build.gradle b/grails-spring-security/rest/redis/build.gradle new file mode 100644 index 00000000000..caed3d69f4d --- /dev/null +++ b/grails-spring-security/rest/redis/build.gradle @@ -0,0 +1,70 @@ +/* + * 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. + */ + +plugins { + id 'groovy' + id 'java-library' + id 'project-report' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.gradle.grails-code-style' + id 'org.apache.grails.gradle.grails-plugin' +} + +version = projectVersion +group = 'org.apache.grails' + +dependencies { + + implementation platform(project(':grails-bom')) + + api project(':grails-spring-security-rest'), { + // api: TokenNotFoundException, TokenStorageService + // impl: SecureRandomTokenGenerator + } + api project(':grails-redis'), { + // api: RedisService + } + api 'org.springframework:spring-core', { + // api: Converter + // impl: SerializingConverter + } + api 'org.springframework.security:spring-security-core', { + // api: UserDetails, UserDetailsService + } + + implementation project(':grails-spring-security'), { + // impl: SecurityFilterPosition, SpringSecurityUtils + } + implementation 'redis.clients:jedis', { + // impl: Jedis + } + + compileOnly 'org.apache.groovy:groovy' // Provided as this is a Grails plugin + compileOnly project(':grails-core') // Provided as this is a Grails plugin +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/spring-security-test-config.gradle') +} diff --git a/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestRedisGrailsPlugin.groovy b/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestRedisGrailsPlugin.groovy new file mode 100644 index 00000000000..25344bc5aa5 --- /dev/null +++ b/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/SpringSecurityRestRedisGrailsPlugin.groovy @@ -0,0 +1,91 @@ +/* + * 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.rest + +import grails.plugin.springsecurity.SecurityFilterPosition +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.rest.token.generation.SecureRandomTokenGenerator +import grails.plugin.springsecurity.rest.token.storage.RedisTokenStorageService +import grails.plugins.Plugin + +class SpringSecurityRestRedisGrailsPlugin extends Plugin { + + // the version or versions of Grails the plugin is designed for + String grailsVersion = '7.0.0 > *' + List loadAfter = ['springSecurityRest'] + List pluginExcludes = [ + 'grails-app/views/**' + ] + + String title = 'Spring Security REST Plugin - Redis support' + String author = 'Alvaro Sanchez-Mariscal' + String authorEmail = '' + String description = 'Implements authentication for REST APIs based on Spring Security. It uses a token-based workflow' + + def profiles = ['web'] + + // URL to the plugin's documentation + String documentation = 'https://apache.github.io/grails-spring-security' + + // Extra (optional) plugin metadata + String license = 'APACHE' + def organization = [name: 'Grails', url: 'https://www.grails.org'] + + def issueManagement = [system: 'GitHub', url: 'https://github.com/apache/grails-spring-security/issues'] + def scm = [url: 'https://github.com/apache/grails-spring-security'] + + Closure doWithSpring() { + { -> + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active || !conf.rest.active) { + return + } + + boolean printStatusMessages = (conf.printStatusMessages instanceof Boolean) ? conf.printStatusMessages : true + + if (printStatusMessages) { + println '\t... with Redis support' + } + + SpringSecurityUtils.loadSecondaryConfig 'DefaultRestRedisSecurityConfig' + conf = SpringSecurityUtils.securityConfig + + SpringSecurityUtils.registerFilter 'restLogoutFilter', SecurityFilterPosition.LOGOUT_FILTER.order - 1 + + tokenStorageService(RedisTokenStorageService) { + redisService = ref('redisService') + userDetailsService = ref('userDetailsService') + expiration = conf.rest.token.storage.redis.expiration + } + + tokenGenerator(SecureRandomTokenGenerator) + } + } + + void doWithApplicationContext() { + def conf = SpringSecurityUtils.securityConfig + if (!conf || !conf.active || !conf.rest.active) { + return + } + + applicationContext.getBean(RestAuthenticationProvider).useJwt = false + } + +} diff --git a/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/redis/Application.groovy b/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/redis/Application.groovy new file mode 100644 index 00000000000..7dbe04d3fe2 --- /dev/null +++ b/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/redis/Application.groovy @@ -0,0 +1,32 @@ +/* + * 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.rest.redis + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration +import grails.plugins.metadata.PluginSource + +@PluginSource +class Application extends GrailsAutoConfiguration { + + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} diff --git a/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/RedisTokenStorageService.groovy b/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/RedisTokenStorageService.groovy new file mode 100644 index 00000000000..476e9124056 --- /dev/null +++ b/grails-spring-security/rest/redis/src/main/groovy/grails/plugin/springsecurity/rest/token/storage/RedisTokenStorageService.groovy @@ -0,0 +1,108 @@ +/* + * 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.rest.token.storage + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import redis.clients.jedis.Jedis + +import org.springframework.core.convert.converter.Converter +import org.springframework.core.serializer.support.SerializingConverter +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService + +import grails.plugins.redis.RedisService + +@Slf4j +@CompileStatic +class RedisTokenStorageService implements TokenStorageService { + + RedisService redisService + UserDetailsService userDetailsService + + /** Expiration in seconds */ + Integer expiration = 3600 + + private static final String PREFIX = 'spring:security:token:' + + Converter serializer = new SerializingConverter() + + @Override + UserDetails loadUserByToken(String tokenValue) throws TokenNotFoundException { + log.debug "Searching in Redis for UserDetails of token ${tokenValue}" + + byte[] userDetails + redisService.withRedis { Jedis jedis -> + String key = buildKey(tokenValue) + userDetails = jedis.get(key.getBytes('UTF-8')) + jedis.expire(key, expiration) + } + + if (userDetails) { + return deserialize(userDetails) as UserDetails + } else { + throw new TokenNotFoundException("Token ${tokenValue} not found") + } + + } + + @Override + void storeToken(String tokenValue, UserDetails principal) { + log.debug "Storing principal for token: ${tokenValue} with expiration of ${expiration} seconds" + log.debug "Principal: ${principal}" + + redisService.withRedis { Jedis jedis -> + String key = buildKey(tokenValue) + jedis.set(key.getBytes('UTF-8'), serialize(principal)) + jedis.expire(key, expiration) + } + } + + @Override + void removeToken(String tokenValue) throws TokenNotFoundException { + log.debug "Removing token: ${tokenValue}" + redisService.withRedis { Jedis jedis -> + jedis.del(buildKey(tokenValue)) + } + } + + private static String buildKey(String token) { + "$PREFIX$token" + } + + private Object deserialize(byte[] bytes) { + new ByteArrayInputStream(bytes).withObjectInputStream(getClass().classLoader) { is -> + return is.readObject() + } + } + + private byte[] serialize(Object object) { + if (object == null) { + return new byte[0] + } else { + try { + return serializer.convert(object) + } catch (Exception var3) { + throw new Exception('Cannot serialize', var3) + } + } + } + +} diff --git a/grails-spring-security/rest/redis/src/main/resources/DefaultRestRedisSecurityConfig.groovy b/grails-spring-security/rest/redis/src/main/resources/DefaultRestRedisSecurityConfig.groovy new file mode 100644 index 00000000000..9f7a0bab1ac --- /dev/null +++ b/grails-spring-security/rest/redis/src/main/resources/DefaultRestRedisSecurityConfig.groovy @@ -0,0 +1,30 @@ +/* + * 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. + */ +security { + rest { + token { + storage { + useRedis = true + redis { + expiration = 3600 + } + } + } + } +} diff --git a/grails-spring-security/rest/renameFiles.groovy b/grails-spring-security/rest/renameFiles.groovy new file mode 100755 index 00000000000..e5fca6665c0 --- /dev/null +++ b/grails-spring-security/rest/renameFiles.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. + */ + +#!/usr/bin/env groovy + +new File(args[0]).eachFileRecurse FILES, { File file -> + if (file.name.contains(' ')) { + file.renameTo(file.absolutePath.replaceAll(' ', '_')) + } +} diff --git a/grails-spring-security/rest/test-app.sh b/grails-spring-security/rest/test-app.sh new file mode 100755 index 00000000000..95e5c88d324 --- /dev/null +++ b/grails-spring-security/rest/test-app.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# +# 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. +# + +set -e +set -x + +[[ ! -z "$BINTRAY_KEY" ]] && echo "bintrayKey=$BINTRAY_KEY" >> ~/.gradle/gradle.properties +[[ ! -z "$PLUGIN_PORTAL_PASSWORD" ]] && echo "pluginPortalPassword=$PLUGIN_PORTAL_PASSWORD" >> ~/.gradle/gradle.properties + +./generate-test-apps.sh + +[[ -z "$CI" ]] && docker-compose up -d +./gradlew :spring-security-rest:check check +[[ -z "$CI" ]] && docker-compose down + +if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then ./gradlew artifactoryPublish; fi \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/.gitignore b/grails-spring-security/rest/testapp-profile/.gitignore new file mode 100644 index 00000000000..153da0c6e65 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/.gitignore @@ -0,0 +1,2 @@ +profile.yml +feature.yml \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/build.gradle b/grails-spring-security/rest/testapp-profile/build.gradle new file mode 100644 index 00000000000..e485a34da40 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/build.gradle @@ -0,0 +1,61 @@ +/* + * 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. + */ + +plugins { + id 'org.apache.grails.gradle.grails-profile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' +} + +version = projectVersion +group = 'org.apache.grails.profiles' + +ext { + pomTitle = 'Grails Spring Security REST Test App Profile' + pomDescription = 'A Grails profile to assist in generating various implementations of the Grails Spring Security REST plugin for testing purposes' + pomDevelopers = [ + alvarosanchez: 'Alvaro Sanchez-Mariscal', + ] +} + +tasks.register('generateProfileConfig') { + copy { + from 'profile.yml.tmpl' + into '.' + rename { String fileName -> fileName.replaceAll '\\.tmpl', '' } + expand(pluginVersion: project.version, grailsVersion: findProperty('projectVersion')) + } + + file('features').eachDir { feature -> + copy { + from "features/${feature.name}/feature.yml.tmpl" + into "features/${feature.name}/" + rename { String fileName -> fileName.replaceAll '\\.tmpl', '' } + expand(pluginVersion: project.version, grailsVersion: findProperty('projectVersion')) + } + } +} + +dependencies { + profileRuntimeApi project(':grails-profiles-base') +} + +tasks.named('compileProfile') { + dependsOn('generateProfileConfig') +} diff --git a/grails-spring-security/rest/testapp-profile/features/data1/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/data1/feature.yml.tmpl new file mode 100644 index 00000000000..d3c2fb23556 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data1/feature.yml.tmpl @@ -0,0 +1,12 @@ +description: First configuration of Grails Data Mapping +dependencies: + - scope: build + coords: "org.apache.grails:grails-data-hibernate5" + - scope: implementation + coords: "org.apache.grails:grails-data-hibernate5" + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest-datamapping:${pluginVersion}" + - scope: runtimeOnly + coords: "com.h2database:h2" + - scope: runtimeOnly + coords: "com.zaxxer:HikariCP" diff --git a/grails-spring-security/rest/testapp-profile/features/data1/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/build.gradle new file mode 100644 index 00000000000..5b05dccb29b --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'true' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..c43de0a8636 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,66 @@ +import org.pac4j.oauth.client.FacebookClient +import org.pac4j.oauth.client.Google2Client +import org.pac4j.oauth.client.TwitterClient + +grails { + plugin { + springsecurity { + + useSecurityEventListener = true + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + + storage { + gorm { + tokenDomainClassName = 'gorm.AccessToken' + } + } + } + + oauth { + frontendCallbackUrl = { String tokenValue -> "http://example.org#token=${tokenValue}" } + + google { + client = Google2Client + key = '1093785205845-hl3jv0rd8jfohkn55jchgmnpvdpsnal4.apps.googleusercontent.com' + secret = 'sWXY3VMm4wKAGoRZg8r3ftZc' + scope = Google2Client.Google2Scope.EMAIL_AND_PROFILE + defaultRoles = ['ROLE_USER', 'ROLE_GOOGLE'] + } + + facebook { + client = FacebookClient + key = '585495051532332' + secret = 'f6bfaff8c66a3fd7b1e9ec4c986fda8b' + + //https://developers.facebook.com/docs/reference/login/ + scope = 'public_profile,email' + fields = 'id,name,first_name,middle_name,last_name,link,gender,email,birthday' + defaultRoles = ['ROLE_USER', 'ROLE_FACEBOOK'] + } + + twitter { + client = TwitterClient + key = 'A2hwgEMfNIp7OF2f05Gqw' + secret = 'BUpumhJGeNskn53Ssr3QQuesKg8lOIEWaLO4pCdgeTw' + defaultRoles = ['ROLE_USER', 'ROLE_TWITTER'] + } + } + } + } + } +} + diff --git a/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/conf/application.yml b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/conf/application.yml new file mode 100644 index 00000000000..5d9212a7242 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/conf/application.yml @@ -0,0 +1,57 @@ +# 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. + +hibernate: + cache: + queries: false + use_second_level_cache: false + use_query_cache: false +dataSource: + pooled: true + jmxExport: true + driverClassName: org.h2.Driver + username: sa + password: '' +environments: + development: + dataSource: + dbCreate: create-drop + url: jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + test: + dataSource: + dbCreate: update + url: jdbc:h2:mem:testDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + production: + dataSource: + dbCreate: none + url: jdbc:h2:./prodDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + properties: + jmxEnabled: true + initialSize: 5 + maxActive: 50 + minIdle: 5 + maxIdle: 25 + maxWait: 10000 + maxAge: 600000 + timeBetweenEvictionRunsMillis: 5000 + minEvictableIdleTimeMillis: 60000 + validationQuery: SELECT 1 + validationQueryTimeout: 3 + validationInterval: 15000 + testOnBorrow: true + testWhileIdle: true + testOnReturn: false + jdbcInterceptors: ConnectionState + defaultTransactionIsolation: 2 # TRANSACTION_READ_COMMITTED \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/domain/gorm/AccessToken.groovy b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/domain/gorm/AccessToken.groovy new file mode 100644 index 00000000000..37815debc1a --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/grails-app/domain/gorm/AccessToken.groovy @@ -0,0 +1,17 @@ +package gorm + +class AccessToken { + + String tokenValue + String username + + static constraints = { + tokenValue nullable: false + username nullable: false + } + + static mapping = { + version false + } + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data1/skeleton/src/integration-test/groovy/rest/RestLogoutFilterSpec.groovy b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/src/integration-test/groovy/rest/RestLogoutFilterSpec.groovy new file mode 100644 index 00000000000..f5addb62932 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data1/skeleton/src/integration-test/groovy/rest/RestLogoutFilterSpec.groovy @@ -0,0 +1,92 @@ +/* + * 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 rest + +import spock.lang.Unroll + +import grails.plugins.rest.client.RestResponse + +class RestLogoutFilterSpec extends AbstractRestSpec { + + void "logout filter can remove a token"() { + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder.post("${baseUrl}/api/logout") { + header 'X-Auth-Token', token + } + + then: + response.status == 200 + + when: + response = restBuilder.get("${baseUrl}/api/validate") { + header 'X-Auth-Token', token + } + + then: + response.status == 401 + } + + void "logout filter returns 404 if token is not found"() { + when: + def response = restBuilder.post("${baseUrl}/api/logout") { + header 'X-Auth-Token', 'whatever' + } + + then: + response.status == 404 + + } + + void "calling /api/logout without token returns 400"() { + when: + def response = restBuilder.post("${baseUrl}/api/logout") + + then: + response.status == 400 + } + + @Unroll + void "#httpMethod requests generate #statusCode responses"() { + + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder."${httpMethod}"("${baseUrl}/api/logout") { + header 'X-Auth-Token', token + } + + then: + response.status == statusCode + + where: + httpMethod | statusCode + 'get' | 405 + 'post' | 200 + 'put' | 405 + 'delete' | 405 + } + + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data2/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/data2/feature.yml.tmpl new file mode 100644 index 00000000000..196a3008038 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data2/feature.yml.tmpl @@ -0,0 +1,12 @@ +description: Second configuration of Grails Data Mapping +dependencies: + - scope: build + coords: "org.apache.grails:grails-data-hibernate5" + - scope: implementation + coords: "org.apache.grails:grails-data-hibernate5" + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest-datamapping:${pluginVersion}" + - scope: runtimeOnly + coords: "com.h2database:h2" + - scope: runtimeOnly + coords: "com.zaxxer:HikariCP" \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data2/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/build.gradle new file mode 100644 index 00000000000..b97a67d78d7 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'false' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..790f6b7ab83 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,29 @@ +grails { + plugin { + springsecurity { + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + storage { + gorm { + tokenDomainClassName = 'gorm.AccessToken' + } + } + } + } + } + } +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/conf/application.yml b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/conf/application.yml new file mode 100644 index 00000000000..7ad7680edb1 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/conf/application.yml @@ -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. + +hibernate: + cache: + queries: false + use_second_level_cache: false + use_query_cache: false +dataSource: + pooled: true + jmxExport: true + driverClassName: org.h2.Driver + username: sa + password: '' + +environments: + development: + dataSource: + dbCreate: create-drop + url: jdbc:h2:mem:devDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + test: + dataSource: + dbCreate: update + url: jdbc:h2:mem:testDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + production: + dataSource: + dbCreate: none + url: jdbc:h2:./prodDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + properties: + jmxEnabled: true + initialSize: 5 + maxActive: 50 + minIdle: 5 + maxIdle: 25 + maxWait: 10000 + maxAge: 600000 + timeBetweenEvictionRunsMillis: 5000 + minEvictableIdleTimeMillis: 60000 + validationQuery: SELECT 1 + validationQueryTimeout: 3 + validationInterval: 15000 + testOnBorrow: true + testWhileIdle: true + testOnReturn: false + jdbcInterceptors: ConnectionState + defaultTransactionIsolation: 2 # TRANSACTION_READ_COMMITTED \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/domain/gorm/AccessToken.groovy b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/domain/gorm/AccessToken.groovy new file mode 100644 index 00000000000..37815debc1a --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/grails-app/domain/gorm/AccessToken.groovy @@ -0,0 +1,17 @@ +package gorm + +class AccessToken { + + String tokenValue + String username + + static constraints = { + tokenValue nullable: false + username nullable: false + } + + static mapping = { + version false + } + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/data2/skeleton/src/integration-test/groovy/rest/RestLogoutFilterSpec.groovy b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/src/integration-test/groovy/rest/RestLogoutFilterSpec.groovy new file mode 100644 index 00000000000..f5addb62932 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/data2/skeleton/src/integration-test/groovy/rest/RestLogoutFilterSpec.groovy @@ -0,0 +1,92 @@ +/* + * 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 rest + +import spock.lang.Unroll + +import grails.plugins.rest.client.RestResponse + +class RestLogoutFilterSpec extends AbstractRestSpec { + + void "logout filter can remove a token"() { + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder.post("${baseUrl}/api/logout") { + header 'X-Auth-Token', token + } + + then: + response.status == 200 + + when: + response = restBuilder.get("${baseUrl}/api/validate") { + header 'X-Auth-Token', token + } + + then: + response.status == 401 + } + + void "logout filter returns 404 if token is not found"() { + when: + def response = restBuilder.post("${baseUrl}/api/logout") { + header 'X-Auth-Token', 'whatever' + } + + then: + response.status == 404 + + } + + void "calling /api/logout without token returns 400"() { + when: + def response = restBuilder.post("${baseUrl}/api/logout") + + then: + response.status == 400 + } + + @Unroll + void "#httpMethod requests generate #statusCode responses"() { + + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder."${httpMethod}"("${baseUrl}/api/logout") { + header 'X-Auth-Token', token + } + + then: + response.status == statusCode + + where: + httpMethod | statusCode + 'get' | 405 + 'post' | 200 + 'put' | 405 + 'delete' | 405 + } + + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/grailscache1/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/grailscache1/feature.yml.tmpl new file mode 100644 index 00000000000..5e2c5db6e8c --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/grailscache1/feature.yml.tmpl @@ -0,0 +1,4 @@ +description: First configuration of Grails cache +dependencies: + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest-grailscache:${pluginVersion}" \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/grailscache1/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/grailscache1/skeleton/build.gradle new file mode 100644 index 00000000000..5b05dccb29b --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/grailscache1/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'true' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/grailscache1/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/grailscache1/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..7be4d5c425c --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/grailscache1/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,60 @@ +import org.pac4j.oauth.client.FacebookClient +import org.pac4j.oauth.client.Google2Client +import org.pac4j.oauth.client.TwitterClient + +grails { + plugin { + springsecurity { + + useSecurityEventListener = true + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + } + + oauth { + frontendCallbackUrl = { String tokenValue -> "http://example.org#token=${tokenValue}" } + + google { + client = Google2Client + key = '1093785205845-hl3jv0rd8jfohkn55jchgmnpvdpsnal4.apps.googleusercontent.com' + secret = 'sWXY3VMm4wKAGoRZg8r3ftZc' + scope = Google2Client.Google2Scope.EMAIL_AND_PROFILE + defaultRoles = ['ROLE_USER', 'ROLE_GOOGLE'] + } + + facebook { + client = FacebookClient + key = '585495051532332' + secret = 'f6bfaff8c66a3fd7b1e9ec4c986fda8b' + + //https://developers.facebook.com/docs/reference/login/ + scope = 'public_profile,email' + fields = 'id,name,first_name,middle_name,last_name,link,gender,email,birthday' + defaultRoles = ['ROLE_USER', 'ROLE_FACEBOOK'] + } + + twitter { + client = TwitterClient + key = 'A2hwgEMfNIp7OF2f05Gqw' + secret = 'BUpumhJGeNskn53Ssr3QQuesKg8lOIEWaLO4pCdgeTw' + defaultRoles = ['ROLE_USER', 'ROLE_TWITTER'] + } + } + } + } + } +} + diff --git a/grails-spring-security/rest/testapp-profile/features/grailscache2/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/grailscache2/feature.yml.tmpl new file mode 100644 index 00000000000..57c78970831 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/grailscache2/feature.yml.tmpl @@ -0,0 +1,4 @@ +description: Second configuration of Grails cache +dependencies: + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest-grailscache:${pluginVersion}" \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/grailscache2/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/grailscache2/skeleton/build.gradle new file mode 100644 index 00000000000..b97a67d78d7 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/grailscache2/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'false' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/grailscache2/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/grailscache2/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..a14765866ed --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/grailscache2/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,24 @@ +grails { + plugin { + springsecurity { + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + } + } + } + } +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/jwt1/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/jwt1/feature.yml.tmpl new file mode 100644 index 00000000000..a6ec362f3b7 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/jwt1/feature.yml.tmpl @@ -0,0 +1 @@ +description: First configuration of JWT \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/build.gradle new file mode 100644 index 00000000000..5b05dccb29b --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'true' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..714462e404e --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,66 @@ +import org.pac4j.oauth.client.FacebookClient +import org.pac4j.oauth.client.Google2Client +import org.pac4j.oauth.client.TwitterClient + +grails { + plugin { + springsecurity { + + useSecurityEventListener = true + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/jwt/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + storage { + jwt { + secret = 'foobar123' * 4 + } + } + } + + oauth { + frontendCallbackUrl = { String tokenValue -> "http://example.org#token=${tokenValue}" } + + google { + client = Google2Client + key = '1093785205845-hl3jv0rd8jfohkn55jchgmnpvdpsnal4.apps.googleusercontent.com' + secret = 'sWXY3VMm4wKAGoRZg8r3ftZc' + scope = Google2Client.Google2Scope.EMAIL_AND_PROFILE + defaultRoles = ['ROLE_USER', 'ROLE_GOOGLE'] + } + + facebook { + client = FacebookClient + key = '585495051532332' + secret = 'f6bfaff8c66a3fd7b1e9ec4c986fda8b' + + //https://developers.facebook.com/docs/reference/login/ + scope = 'public_profile,email' + fields = 'id,name,first_name,middle_name,last_name,link,gender,email,birthday' + defaultRoles = ['ROLE_USER', 'ROLE_FACEBOOK'] + } + + twitter { + client = TwitterClient + key = 'A2hwgEMfNIp7OF2f05Gqw' + secret = 'BUpumhJGeNskn53Ssr3QQuesKg8lOIEWaLO4pCdgeTw' + defaultRoles = ['ROLE_USER', 'ROLE_TWITTER'] + } + } + } + } + } +} + diff --git a/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/grails-app/controllers/rest/JwtController.groovy b/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/grails-app/controllers/rest/JwtController.groovy new file mode 100644 index 00000000000..2c55474bea7 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/grails-app/controllers/rest/JwtController.groovy @@ -0,0 +1,17 @@ +package rest + +import grails.converters.JSON +import grails.plugin.springsecurity.annotation.Secured +import grails.plugin.springsecurity.rest.token.AccessToken + +class JwtController { + + def springSecurityService + + @Secured(['ROLE_USER']) + def claims() { + AccessToken accessToken = springSecurityService.authentication as AccessToken + render accessToken.accessTokenJwt.JWTClaimsSet.claims as JSON + } + +} diff --git a/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/src/integration-test/groovy/rest/JwtRestTokenValidationFilterSpec.groovy b/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/src/integration-test/groovy/rest/JwtRestTokenValidationFilterSpec.groovy new file mode 100644 index 00000000000..f34b312ddfe --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/jwt1/skeleton/src/integration-test/groovy/rest/JwtRestTokenValidationFilterSpec.groovy @@ -0,0 +1,49 @@ +/* + * 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 rest + +import spock.lang.IgnoreIf +import spock.lang.Subject + +import grails.plugin.springsecurity.rest.RestTokenValidationFilter +import grails.plugins.rest.client.RestResponse + +@IgnoreIf({ System.getProperty('useBearerToken', 'false').toBoolean() }) +@Subject(RestTokenValidationFilter) +class JwtRestTokenValidationFilterSpec extends AbstractRestSpec { + + void "the claims are available to the controller"() { + given: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String token = authResponse.json.access_token + + when: + def response = restBuilder.get("${baseUrl}/jwt/claims") { + header 'X-Auth-Token', token + } + + then: + response.status == 200 + response.json.sub == 'jimi' + response.json.exp + response.json.iat + response.json.roles.size() == 2 + } + +} diff --git a/grails-spring-security/rest/testapp-profile/features/jwt2/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/jwt2/feature.yml.tmpl new file mode 100644 index 00000000000..c5f06b4bfaa --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/jwt2/feature.yml.tmpl @@ -0,0 +1 @@ +description: Second configuration of JWT \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/jwt2/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/jwt2/skeleton/build.gradle new file mode 100644 index 00000000000..d98bbbe12a4 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/jwt2/skeleton/build.gradle @@ -0,0 +1,19 @@ + +task restoreKeys() { + def dir = file("${System.getProperty("user.home")}/.grails/spring-security-rest") + + copy { + from dir + include "*.der" + + into "grails-app/conf/" + } +} + +integrationTest { + systemProperty 'useBearerToken', 'true' + systemProperty 'useFacebook', 'false' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} + +test.dependsOn restoreKeys \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/jwt2/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/jwt2/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..d4b5be5fd72 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/jwt2/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,36 @@ +grails { + plugin { + springsecurity { + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = true + } + + storage { + jwt { + useEncryptedJwt = true + + privateKeyPath = "grails-app/conf/private_key.der" + publicKeyPath = "grails-app/conf/public_key.der" + + expiration = 5 + } + } + } + } + } + } +} + diff --git a/grails-spring-security/rest/testapp-profile/features/memcached1/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/memcached1/feature.yml.tmpl new file mode 100644 index 00000000000..9ece535e07d --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/memcached1/feature.yml.tmpl @@ -0,0 +1,4 @@ +description: First configuration of Memcached +dependencies: + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest-memcached:${pluginVersion}" \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/build.gradle new file mode 100644 index 00000000000..5b05dccb29b --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'true' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..41f340b9097 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,60 @@ +import org.pac4j.oauth.client.FacebookClient +import org.pac4j.oauth.client.Google2Client +import org.pac4j.oauth.client.TwitterClient + +grails { + plugin { + springsecurity { + + useSecurityEventListener = true + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + } + + oauth { + frontendCallbackUrl = { String tokenValue -> "http://example.org#token=${tokenValue}" } + + google { + client = Google2Client + key = 'TODO' + secret = 'TODO' + scope = Google2Client.Google2Scope.EMAIL_AND_PROFILE + defaultRoles = ['ROLE_USER', 'ROLE_GOOGLE'] + } + + facebook { + client = FacebookClient + key = 'TODO' + secret = 'TODO' + + //https://developers.facebook.com/docs/reference/login/ + scope = 'public_profile,email' + fields = 'id,name,first_name,middle_name,last_name,link,gender,email,birthday' + defaultRoles = ['ROLE_USER', 'ROLE_FACEBOOK'] + } + + twitter { + client = TwitterClient + key = 'TODO' + secret = 'TODO' + defaultRoles = ['ROLE_USER', 'ROLE_TWITTER'] + } + } + } + } + } +} + diff --git a/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/src/integration-test/groovy/memcached/MemcachedSpec.groovy b/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/src/integration-test/groovy/memcached/MemcachedSpec.groovy new file mode 100644 index 00000000000..68490cff651 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/memcached1/skeleton/src/integration-test/groovy/memcached/MemcachedSpec.groovy @@ -0,0 +1,104 @@ +/* + * 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 memcached + +import net.spy.memcached.MemcachedClient +import rest.AbstractRestSpec +import spock.lang.Issue +import spock.lang.Shared +import spock.lang.Unroll + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugin.springsecurity.rest.token.storage.memcached.MemcachedTokenStorageService + +class MemcachedSpec extends AbstractRestSpec { + + @Autowired + MemcachedClient memcachedClient + + @Shared + MemcachedTokenStorageService memcachedTokenStorageService + + @Autowired + void setTokenStorageService(MemcachedTokenStorageService tokenStorageService) { + this.memcachedTokenStorageService = tokenStorageService + } + + void cleanupSpec() { + memcachedTokenStorageService.expiration = 3600 + } + + @Unroll + void "Memcached connection works for storing #key's"() { + + when: + memcachedClient.set(key, 3600, object) + + then: + memcachedClient.get(key) == object + + where: + key | object + 'String' | 'My cool string value' + 'Date' | new Date() + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/86") + void "Objects stored expire after the expiration time"() { + given: + memcachedTokenStorageService.expiration = 1 + UserDetails principal = new User('username', 'password', []) + String token = 'abcd' + System.currentTimeMillis() + memcachedTokenStorageService.storeToken(token, principal) + Thread.sleep(1500) + + when: + memcachedTokenStorageService.loadUserByToken(token) + + then: + thrown(TokenNotFoundException) + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/86") + void "Objects are refreshed when accessed"() { + given: + memcachedTokenStorageService.expiration = 2 + UserDetails principal = new User('username', 'password', []) + String token = 'abcd' + System.currentTimeMillis() + memcachedTokenStorageService.storeToken(token, principal) + Thread.sleep(1000) + + when: "it is accessed within the expiration time" + Object details = memcachedTokenStorageService.loadUserByToken(token) + + then: "it is found, and expiration time reset to 2 sencods" + details + + when: "it is accessed after one second" + Thread.sleep(1000) + memcachedTokenStorageService.loadUserByToken(token) + + then: "is still found" + notThrown(TokenNotFoundException) + } +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/memcached2/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/memcached2/feature.yml.tmpl new file mode 100644 index 00000000000..7c16624cde0 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/memcached2/feature.yml.tmpl @@ -0,0 +1,4 @@ +description: Second configuration of Memcached +dependencies: + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest-memcached:${pluginVersion}" \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/build.gradle new file mode 100644 index 00000000000..b97a67d78d7 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'false' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..4a3af651b7d --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,25 @@ +grails { + plugin { + springsecurity { + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + } + } + } + } +} + diff --git a/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/src/integration-test/groovy/memcached/MemcachedSpec.groovy b/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/src/integration-test/groovy/memcached/MemcachedSpec.groovy new file mode 100644 index 00000000000..68490cff651 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/memcached2/skeleton/src/integration-test/groovy/memcached/MemcachedSpec.groovy @@ -0,0 +1,104 @@ +/* + * 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 memcached + +import net.spy.memcached.MemcachedClient +import rest.AbstractRestSpec +import spock.lang.Issue +import spock.lang.Shared +import spock.lang.Unroll + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugin.springsecurity.rest.token.storage.memcached.MemcachedTokenStorageService + +class MemcachedSpec extends AbstractRestSpec { + + @Autowired + MemcachedClient memcachedClient + + @Shared + MemcachedTokenStorageService memcachedTokenStorageService + + @Autowired + void setTokenStorageService(MemcachedTokenStorageService tokenStorageService) { + this.memcachedTokenStorageService = tokenStorageService + } + + void cleanupSpec() { + memcachedTokenStorageService.expiration = 3600 + } + + @Unroll + void "Memcached connection works for storing #key's"() { + + when: + memcachedClient.set(key, 3600, object) + + then: + memcachedClient.get(key) == object + + where: + key | object + 'String' | 'My cool string value' + 'Date' | new Date() + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/86") + void "Objects stored expire after the expiration time"() { + given: + memcachedTokenStorageService.expiration = 1 + UserDetails principal = new User('username', 'password', []) + String token = 'abcd' + System.currentTimeMillis() + memcachedTokenStorageService.storeToken(token, principal) + Thread.sleep(1500) + + when: + memcachedTokenStorageService.loadUserByToken(token) + + then: + thrown(TokenNotFoundException) + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/86") + void "Objects are refreshed when accessed"() { + given: + memcachedTokenStorageService.expiration = 2 + UserDetails principal = new User('username', 'password', []) + String token = 'abcd' + System.currentTimeMillis() + memcachedTokenStorageService.storeToken(token, principal) + Thread.sleep(1000) + + when: "it is accessed within the expiration time" + Object details = memcachedTokenStorageService.loadUserByToken(token) + + then: "it is found, and expiration time reset to 2 sencods" + details + + when: "it is accessed after one second" + Thread.sleep(1000) + memcachedTokenStorageService.loadUserByToken(token) + + then: "is still found" + notThrown(TokenNotFoundException) + } +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/redis1/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/redis1/feature.yml.tmpl new file mode 100644 index 00000000000..3198d79ca33 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/redis1/feature.yml.tmpl @@ -0,0 +1,6 @@ +description: First configuration of Redis +dependencies: + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest-redis:${pluginVersion}" + - scope: implementation + coords: "org.apache.grails:grails-redis:5.0.0" \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/build.gradle new file mode 100644 index 00000000000..5b05dccb29b --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'true' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..7be4d5c425c --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,60 @@ +import org.pac4j.oauth.client.FacebookClient +import org.pac4j.oauth.client.Google2Client +import org.pac4j.oauth.client.TwitterClient + +grails { + plugin { + springsecurity { + + useSecurityEventListener = true + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + } + + oauth { + frontendCallbackUrl = { String tokenValue -> "http://example.org#token=${tokenValue}" } + + google { + client = Google2Client + key = '1093785205845-hl3jv0rd8jfohkn55jchgmnpvdpsnal4.apps.googleusercontent.com' + secret = 'sWXY3VMm4wKAGoRZg8r3ftZc' + scope = Google2Client.Google2Scope.EMAIL_AND_PROFILE + defaultRoles = ['ROLE_USER', 'ROLE_GOOGLE'] + } + + facebook { + client = FacebookClient + key = '585495051532332' + secret = 'f6bfaff8c66a3fd7b1e9ec4c986fda8b' + + //https://developers.facebook.com/docs/reference/login/ + scope = 'public_profile,email' + fields = 'id,name,first_name,middle_name,last_name,link,gender,email,birthday' + defaultRoles = ['ROLE_USER', 'ROLE_FACEBOOK'] + } + + twitter { + client = TwitterClient + key = 'A2hwgEMfNIp7OF2f05Gqw' + secret = 'BUpumhJGeNskn53Ssr3QQuesKg8lOIEWaLO4pCdgeTw' + defaultRoles = ['ROLE_USER', 'ROLE_TWITTER'] + } + } + } + } + } +} + diff --git a/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/src/integration-test/groovy/redis/RedisSpec.groovy b/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/src/integration-test/groovy/redis/RedisSpec.groovy new file mode 100644 index 00000000000..15e236b0efd --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/redis1/skeleton/src/integration-test/groovy/redis/RedisSpec.groovy @@ -0,0 +1,86 @@ +/* + * 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 redis + +import spock.lang.Specification + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.storage.RedisTokenStorageService +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugins.redis.RedisService +import grails.testing.mixin.integration.Integration + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT + +@Integration +@SpringBootTest(webEnvironment = DEFINED_PORT) +class RedisSpec extends Specification { + + @Autowired + RedisTokenStorageService tokenStorageService + + @Autowired + RedisService redisService + + def cleanup() { + redisService.flushDB() + } + + void "Objects stored expire after the expiration time"() { + given: + tokenStorageService.expiration = 1 + UserDetails principal = new User('username', 'password', []) + String token = 'abcd' + tokenStorageService.storeToken(token, principal) + Thread.sleep(1000) + + when: + tokenStorageService.loadUserByToken(token) + + then: + thrown(TokenNotFoundException) + } + + void "Objects are refreshed when accessed"() { + given: + tokenStorageService.expiration = 2 + UserDetails principal = new User('username', 'password', []) + String token = 'abcd' + System.currentTimeMillis() + tokenStorageService.storeToken(token, principal) + Thread.sleep(1000) + + when: "it is accessed within the expiration time" + Object details = tokenStorageService.loadUserByToken(token) + + then: "it is found, and expiration time reset to 2 sencods" + details + + when: "it is accessed after one second" + Thread.sleep(1000) + tokenStorageService.loadUserByToken(token) + + then: "is still found" + notThrown(TokenNotFoundException) + } + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/redis2/feature.yml.tmpl b/grails-spring-security/rest/testapp-profile/features/redis2/feature.yml.tmpl new file mode 100644 index 00000000000..f62bc5fb007 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/redis2/feature.yml.tmpl @@ -0,0 +1,6 @@ +description: Second configuration of Redis +dependencies: + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest-redis:${pluginVersion}" + - scope: implementation + coords: "org.apache.grails:grails-redis:5.0.0" \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/build.gradle new file mode 100644 index 00000000000..b97a67d78d7 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/build.gradle @@ -0,0 +1,6 @@ + +integrationTest { + systemProperty 'useBearerToken', 'false' + systemProperty 'useFacebook', 'false' + systemProperty 'geb.env', System.getProperty('geb.env', 'phantomJs') +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/grails-app/conf/application.groovy b/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/grails-app/conf/application.groovy new file mode 100644 index 00000000000..a14765866ed --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/grails-app/conf/application.groovy @@ -0,0 +1,24 @@ +grails { + plugin { + springsecurity { + + filterChain { + chainMap = [ + [pattern: '/api/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/secured/**', filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter'], + [pattern: '/anonymous/**', filters: 'anonymousAuthenticationFilter,restTokenValidationFilter,restExceptionTranslationFilter,filterInvocationInterceptor'], + [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] + ] + } + + rest { + token { + validation { + enableAnonymousAccess = true + useBearerToken = false + } + } + } + } + } +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/src/integration-test/groovy/redis/RedisSpec.groovy b/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/src/integration-test/groovy/redis/RedisSpec.groovy new file mode 100644 index 00000000000..15e236b0efd --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/features/redis2/skeleton/src/integration-test/groovy/redis/RedisSpec.groovy @@ -0,0 +1,86 @@ +/* + * 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 redis + +import spock.lang.Specification + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails + +import grails.plugin.springsecurity.rest.token.storage.RedisTokenStorageService +import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException +import grails.plugins.redis.RedisService +import grails.testing.mixin.integration.Integration + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT + +@Integration +@SpringBootTest(webEnvironment = DEFINED_PORT) +class RedisSpec extends Specification { + + @Autowired + RedisTokenStorageService tokenStorageService + + @Autowired + RedisService redisService + + def cleanup() { + redisService.flushDB() + } + + void "Objects stored expire after the expiration time"() { + given: + tokenStorageService.expiration = 1 + UserDetails principal = new User('username', 'password', []) + String token = 'abcd' + tokenStorageService.storeToken(token, principal) + Thread.sleep(1000) + + when: + tokenStorageService.loadUserByToken(token) + + then: + thrown(TokenNotFoundException) + } + + void "Objects are refreshed when accessed"() { + given: + tokenStorageService.expiration = 2 + UserDetails principal = new User('username', 'password', []) + String token = 'abcd' + System.currentTimeMillis() + tokenStorageService.storeToken(token, principal) + Thread.sleep(1000) + + when: "it is accessed within the expiration time" + Object details = tokenStorageService.loadUserByToken(token) + + then: "it is found, and expiration time reset to 2 sencods" + details + + when: "it is accessed after one second" + Thread.sleep(1000) + tokenStorageService.loadUserByToken(token) + + then: "is still found" + notThrown(TokenNotFoundException) + } + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/keys/private_key.der b/grails-spring-security/rest/testapp-profile/keys/private_key.der new file mode 100644 index 00000000000..9f8a507473d Binary files /dev/null and b/grails-spring-security/rest/testapp-profile/keys/private_key.der differ diff --git a/grails-spring-security/rest/testapp-profile/keys/public_key.der b/grails-spring-security/rest/testapp-profile/keys/public_key.der new file mode 100644 index 00000000000..e34c4385a88 Binary files /dev/null and b/grails-spring-security/rest/testapp-profile/keys/public_key.der differ diff --git a/grails-spring-security/rest/testapp-profile/profile.yml.tmpl b/grails-spring-security/rest/testapp-profile/profile.yml.tmpl new file mode 100644 index 00000000000..15af71bd7fc --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/profile.yml.tmpl @@ -0,0 +1,52 @@ +description: Creates a test app for Spring Security REST plugin +build: + plugins: + - org.gradle.test-retry + - com.adarshr.test-logger + excludes: + - org.apache.grails.gradle.grails-web +dependencies: + - scope: build + coords: "org.gradle:test-retry-gradle-plugin:1.6.4" + - scope: build + coords: "com.adarshr.test-logger:com.adarshr.test-logger.gradle.plugin:4.0.0" + - scope: implementation + coords: "org.apache.grails:grails-spring-security-rest:${pluginVersion}" + - scope: implementation + coords: "org.springframework.security:spring-security-core" + - scope: testImplementation + coords: "io.github.gpc:grails-datastore-rest-client-legacy:7.0.1" + - scope: integrationTestImplementation + coords: 'testFixtures("org.apache.grails:grails-geb")' + - scope: testImplementation + coords: "com.codeborne:phantomjsdriver:1.5.0" + - scope: testImplementation + coords: "org.seleniumhq.selenium:selenium-api" + - scope: testImplementation + coords: "org.seleniumhq.selenium:selenium-remote-driver" + - scope: testImplementation + coords: "org.seleniumhq.selenium:selenium-firefox-driver" + - scope: implementation + coords: "org.apache.httpcomponents:httpclient:4.5.14" + - scope: implementation + coords: "jakarta.servlet:jakarta.servlet-api" + - scope: implementation + coords: "org.pac4j:pac4j-oauth:6.2.2" + - scope: implementation + coords: "org.springframework.boot:spring-boot-starter-actuator" + - scope: implementation + coords: "org.springframework.boot:spring-boot-starter-tomcat" + - scope: implementation + coords: "org.apache.grails:grails-web-boot" + - scope: implementation + coords: "org.apache.grails:grails-logging" + - scope: implementation + coords: "org.apache.grails:grails-rest-transforms" + - scope: implementation + coords: "org.apache.grails:grails-databinding" + - scope: implementation + coords: "org.apache.grails:grails-services" + - scope: implementation + coords: "org.apache.grails:grails-url-mappings" + - scope: implementation + coords: "org.apache.grails:grails-interceptors" \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/skeleton/build.gradle b/grails-spring-security/rest/testapp-profile/skeleton/build.gradle new file mode 100644 index 00000000000..173b31bd49a --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/build.gradle @@ -0,0 +1,14 @@ +testlogger { + showFullStackTraces true + showStandardStreams true + showPassedStandardStreams false + showSkippedStandardStreams false + showFailedStandardStreams true +} + +tasks.withType(Test) { + retry { + maxRetries = 3 + maxFailures = 20 + } +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/application.yml b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/application.yml new file mode 100644 index 00000000000..ad594e2dac6 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/application.yml @@ -0,0 +1,17 @@ +# 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. + +server: + port: 8080 \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/logback-spring.xml b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/logback-spring.xml new file mode 100644 index 00000000000..ac7a500ee8e --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/logback-spring.xml @@ -0,0 +1,44 @@ + + + + + + false + + ${CONSOLE_LOG_THRESHOLD} + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/spring/resources.groovy b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/spring/resources.groovy new file mode 100644 index 00000000000..a345f553ff7 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/conf/spring/resources.groovy @@ -0,0 +1,11 @@ +import org.springframework.security.provisioning.InMemoryUserDetailsManager + +// Place your Spring DSL code here +beans = { + + userDetailsService(InMemoryUserDetailsManager, []) + + //passwordEncoder(PlaintextPasswordEncoder) + + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/UrlMappings.groovy b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/UrlMappings.groovy new file mode 100644 index 00000000000..bf8ee4944e4 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/UrlMappings.groovy @@ -0,0 +1,14 @@ +class UrlMappings { + + static mappings = { + "/$controller/$action?/$id?(.$format)?" { + constraints { + // apply constraints here + } + } + + "/"(view: "/index") + "500"(view: '/error') + "404"(view: '/notFound') + } +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/AnonymousController.groovy b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/AnonymousController.groovy new file mode 100644 index 00000000000..b557c57a521 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/AnonymousController.groovy @@ -0,0 +1,11 @@ +package rest + +import grails.plugin.springsecurity.annotation.Secured + +@Secured(['permitAll']) +class AnonymousController { + + def index() { + render "Hi" + } +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/PublicController.groovy b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/PublicController.groovy new file mode 100644 index 00000000000..c1abd6ca022 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/PublicController.groovy @@ -0,0 +1,8 @@ +package rest + +class PublicController { + + def index() { + render "Hi!" + } +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/SecuredController.groovy b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/SecuredController.groovy new file mode 100644 index 00000000000..eef42678fcc --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/controllers/rest/SecuredController.groovy @@ -0,0 +1,18 @@ +package rest + +import grails.plugin.springsecurity.annotation.Secured + +class SecuredController { + + def springSecurityService + + @Secured(['ROLE_USER']) + def index() { + render springSecurityService.principal.username + } + + @Secured(['ROLE_SUPER_ADMIN']) + def superAdmin() { + render springSecurityService.principal.username + } +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/grails-app/init/BootStrap.groovy b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/init/BootStrap.groovy new file mode 100644 index 00000000000..203d1a49721 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/grails-app/init/BootStrap.groovy @@ -0,0 +1,20 @@ +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.provisioning.InMemoryUserDetailsManager + +class BootStrap { + + InMemoryUserDetailsManager userDetailsService + + def init = { servletContext -> + UserDetails jimi = new User('jimi', '{noop}jimispassword', [new SimpleGrantedAuthority('ROLE_USER'), new SimpleGrantedAuthority('ROLE_ADMIN')]) + userDetailsService.createUser(jimi) + + UserDetails alvaro = new User('115537660854424164575', '{noop}N/A', [new SimpleGrantedAuthority('ROLE_USER'), new SimpleGrantedAuthority('ROLE_ADMIN')]) + userDetailsService.createUser(alvaro) + } + + def destroy = { + } +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/GebConfig.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/GebConfig.groovy new file mode 100644 index 00000000000..f8e67e5cdcf --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/GebConfig.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. + */ + +import org.openqa.selenium.Dimension +import org.openqa.selenium.firefox.FirefoxDriver +import org.openqa.selenium.firefox.FirefoxProfile +import org.openqa.selenium.phantomjs.PhantomJSDriver +import org.openqa.selenium.remote.DesiredCapabilities + +environments { + phantomJs { + + } + + firefox { + driver = { + //set the firefox locale to 'en-us' since the tests expect english + //see http://stackoverflow.com/questions/9822717 for more details + FirefoxProfile profile = new FirefoxProfile() + profile.setPreference("intl.accept_languages", "en-uk") + def driverInstance = new FirefoxDriver(profile) + driverInstance.manage().window().maximize() + driverInstance + } + + baseNavigatorWaiting = true + atCheckWaiting = true + } +} + +driver = { + def capabilities = new DesiredCapabilities() + capabilities.setCapability("phantomjs.page.customHeaders.Accept-Language", "en-UK") + def d = new PhantomJSDriver(capabilities) + d.manage().window().setSize(new Dimension(1028, 768)) + return d +} + +atCheckWaiting = true +baseNavigatorWaiting = true +waiting { + timeout = 10 + retryInterval = 0.5 +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/org/openqa/selenium/browserlaunchers/Proxies.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/org/openqa/selenium/browserlaunchers/Proxies.groovy new file mode 100644 index 00000000000..ca3b7f32fb6 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/org/openqa/selenium/browserlaunchers/Proxies.groovy @@ -0,0 +1,10 @@ +package org.openqa.selenium.browserlaunchers + +import org.openqa.selenium.Capabilities + +class Proxies { + + static Proxy extractProxy(Capabilities capabilities) { + return Proxy.extractFrom(capabilities) + } +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/AbstractRestSpec.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/AbstractRestSpec.groovy new file mode 100644 index 00000000000..8d04b83b554 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/AbstractRestSpec.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 rest + +import spock.lang.Shared +import spock.lang.Specification + +import org.springframework.boot.test.context.SpringBootTest + +import grails.plugins.rest.client.RestBuilder +import grails.testing.mixin.integration.Integration + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT + +@Integration +@SpringBootTest(webEnvironment = DEFINED_PORT) +abstract class AbstractRestSpec extends Specification { + + @Shared + ConfigObject config = new ConfigSlurper().parse(new File('grails-app/conf/application.groovy').toURL()) + + @Shared + RestBuilder restBuilder = new RestBuilder() + + String getBaseUrl() { + "http://localhost:8080" + } + + def sendWrongCredentials() { + if (config.grails.plugin.springsecurity.rest.login.useRequestParamsCredentials == true) { + restBuilder.post("${baseUrl}/api/login?username=foo&password=bar") + } else { + restBuilder.post("${baseUrl}/api/login") { + json { + username = 'foo' + password = 'bar' + } + } + } + } + + def sendCorrectCredentials(String u = 'jimi', String p = 'jimispassword') { + if (config.grails.plugin.springsecurity.rest.login.useRequestParamsCredentials == true) { + restBuilder.post("${baseUrl}/api/login?username=${u}&password=${p}") + } else { + restBuilder.post("${baseUrl}/api/login") { + json { + username = u + password = p + } + } + } + } + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/BearerTokenSpec.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/BearerTokenSpec.groovy new file mode 100644 index 00000000000..50a45df89e4 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/BearerTokenSpec.groovy @@ -0,0 +1,159 @@ +/* + * 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 rest + +import spock.lang.IgnoreIf +import spock.lang.Issue + +import grails.plugins.rest.client.RestResponse + +@IgnoreIf({ !System.getProperty('useBearerToken', 'false').toBoolean() }) +@Issue("https://github.com/grails/grails-spring-security-rest/issues/73") +class BearerTokenSpec extends AbstractRestSpec { + + void "access token response is compliant with the specification"() { + when: + RestResponse response = sendCorrectCredentials() + + then: + response.status == 200 + response.headers.getFirst('Content-Type') == 'application/json;charset=UTF-8' + response.headers.getFirst('Cache-Control') == 'no-store' + response.headers.getFirst('Pragma') == 'no-cache' + response.json.access_token + response.json.token_type == 'Bearer' + + } + + void "authorisation header is checked to read token value"() { + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder.get("${baseUrl}/secured") { + header 'Authorization', "Bearer ${token}" + } + + then: + response.status == 200 + } + + void "Form-Encoded body parameter can be used"() { + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder.post("${baseUrl}/secured") { + contentType 'application/x-www-form-urlencoded' + body "access_token=${token}".toString() + } + + then: + response.status == 200 + } + + void "query string can be used"() { + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder.get("${baseUrl}/secured?access_token=${token}") + + then: + response.status == 200 + + } + + void "if credentials are required but missing, the response contains WWW-Authenticate header"() { + when: + RestResponse response = restBuilder.post("${baseUrl}/secured") { + contentType 'application/x-www-form-urlencoded' + } + + then: + response.status == 401 + response.headers.getFirst('WWW-Authenticate') == 'Bearer' + } + + void "if the token is invalid, it is indicated in the header"() { + when: + RestResponse response = restBuilder.get("${baseUrl}/secured") { + header 'Authorization', "Bearer wrongTokenValue" + } + + then: + response.status == 401 + response.headers.getFirst('WWW-Authenticate') == 'Bearer error="invalid_token"' + } + + void "when accessing a secured object with a non-bearer request, it's considered a unauthorized request"() { + when: + RestResponse response = restBuilder.post("${baseUrl}/secured") { + contentType 'text/plain' + body "{hi:777}" + } + + then: + response.status == 401 + response.headers.getFirst('WWW-Authenticate') == 'Bearer' + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/98") + void "accessing Anonymous without a token, responds ok"() { + when: + def response = restBuilder.get("${baseUrl}/anonymous") { + contentType 'application/json;charset=UTF-8' + } + + then: + response.status == 200 + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/98") + void "accessing Secured without a token, responds Unauthorized"() { + when: + RestResponse response = restBuilder.post("${baseUrl}/secured") { + contentType 'application/json;charset=UTF-8' + body "{hi:777}" + } + + then: + response.status == 401 + response.headers.getFirst('WWW-Authenticate') == 'Bearer' + } + + void "accessing Secured with valid token, but not authorized responds forbidden"() { + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder.get("${baseUrl}/secured/superAdmin") { + header 'Authorization', "Bearer ${token}" + } + + then: + response.status == 403 + response.headers.getFirst('WWW-Authenticate') == 'Bearer error="insufficient_scope"' + } + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/CorsSpec.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/CorsSpec.groovy new file mode 100644 index 00000000000..68843947226 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/CorsSpec.groovy @@ -0,0 +1,52 @@ +/* + * 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 rest + +import org.apache.http.HttpResponse +import org.apache.http.client.HttpClient +import org.apache.http.client.methods.HttpOptions +import org.apache.http.impl.client.DefaultHttpClient +import spock.lang.Ignore + +/** + * Specification to test CORS support + * + * @see https://github.com/grails/grails-spring-security-rest/issues/4 + */ +//FIXME +@Ignore +class CorsSpec extends AbstractRestSpec { + + void "OPTIONS requests are allowed"() { + + given: + HttpClient client = new DefaultHttpClient() + HttpOptions options = new HttpOptions("${baseUrl}/api/login") + options.addHeader 'Origin', 'http://www.example.com' + options.addHeader 'Access-Control-Request-Method', 'POST' + + when: + HttpResponse response = client.execute(options) + + then: + response.getHeaders('Access-Control-Allow-Origin').first().value == 'http://www.example.com' + + } + +} \ No newline at end of file diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/FrontendCallbackPage.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/FrontendCallbackPage.groovy new file mode 100644 index 00000000000..a3e1fcaa14e --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/FrontendCallbackPage.groovy @@ -0,0 +1,32 @@ +/* + * 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 rest + +import geb.Page + +class FrontendCallbackPage extends Page { + + static at = { + jsUrl.startsWith "http://example.org/" + } + + static content = { + jsUrl { js."window.document.location.toString()" } + } +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/JwtSpec.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/JwtSpec.groovy new file mode 100644 index 00000000000..c25085cbc34 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/JwtSpec.groovy @@ -0,0 +1,230 @@ +/* + * 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 rest + +import com.nimbusds.jwt.JWT +import spock.lang.IgnoreIf +import spock.lang.Issue +import spock.lang.Unroll + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.core.userdetails.User +import org.springframework.security.provisioning.InMemoryUserDetailsManager + +import grails.plugin.springsecurity.rest.JwtService +import grails.plugins.rest.client.RestResponse + +@IgnoreIf({ !System.getProperty('useBearerToken', 'false').toBoolean() }) +class JwtSpec extends AbstractRestSpec { + + @Autowired + JwtService jwtService + + @Autowired + InMemoryUserDetailsManager userDetailsManager + + void "token expiration applies"() { + given: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String accessToken = authResponse.json.access_token + + when: + def response = restBuilder.post("${baseUrl}/api/validate") { + header 'Authorization', "Bearer ${accessToken}" + } + + then: + response.status == 200 + response.json.username == 'jimi' + response.json.access_token + response.json.roles.size() == 2 + + when: + Thread.sleep 5000 + response = restBuilder.post("${baseUrl}/api/validate") { + header 'Authorization', "Bearer ${accessToken}" + } + + then: + response.status == 401 + } + + void "remaining time to expiration is reflected when validating a token"() { + given: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String accessToken = authResponse.json.access_token + + when: + Thread.sleep 1000 + def response = restBuilder.post("${baseUrl}/api/validate") { + header 'Authorization', "Bearer ${accessToken}" + } + + then: + response.status == 200 + response.json.expires_in + response.json.expires_in < 5 + } + + void "refresh tokens are generated"() { + when: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + + then: + authResponse.json.access_token + authResponse.json.refresh_token + } + + void "refresh tokens can be used to obtain access tokens"() { + given: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String refreshToken = authResponse.json.refresh_token + + when: + def response = restBuilder.post("${baseUrl}/oauth/access_token") { + contentType "application/x-www-form-urlencoded" + body "grant_type=refresh_token&refresh_token=${refreshToken}".toString() + } + + then: + response.json.access_token + + and: + response.json.refresh_token == refreshToken + } + + void "refresh token is required to send the refresh token"() { + when: + def response = restBuilder.post("${baseUrl}/oauth/access_token") { + contentType "application/x-www-form-urlencoded" + body "grant_type=refresh_token".toString() + } + + then: + response.status == 400 + } + + void "grant_type is required to send the refresh token"() { + when: + def response = restBuilder.post("${baseUrl}/oauth/access_token") { + contentType "application/x-www-form-urlencoded" + body "refresh_token=whatever".toString() + } + + then: + response.status == 400 + } + + @Unroll + void "#method.toUpperCase() HTTP method produces a #status response code when requesting the refresh token endpoint"() { + given: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String refreshToken = authResponse.json.refresh_token + + when: + def response = restBuilder."${method}"("${baseUrl}/oauth/access_token") { + contentType "application/x-www-form-urlencoded" + body "grant_type=refresh_token&refresh_token=${refreshToken}".toString() + } + + then: + response.status == status + + where: + method | status + 'get' | 405 + 'post' | 200 + 'put' | 405 + 'delete' | 405 + } + + void "an invalid refresh token is rejected as forbidden"() { + when: + def response = restBuilder.post("${baseUrl}/oauth/access_token") { + contentType "application/x-www-form-urlencoded" + body "grant_type=refresh_token&refresh_token=thisIsNotAJWT".toString() + } + + then: + response.status == 403 + } + + void "issuer is set via a custom claim provider"() { + given: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String accessToken = authResponse.json.access_token + + when: + JWT jwt = jwtService.parse(accessToken) + + then: + jwt.JWTClaimsSet.issuer == 'Spring Security REST Grails Plugin' + } + + @Issue("https://github.com/grails/grails-spring-security-rest/pull/344") + void "if the user no longer exists, token can't be refreshed"() { + given: + userDetailsManager.createUser(new User('foo', '{noop}password', [])) + RestResponse authResponse = sendCorrectCredentials('foo', 'password') as RestResponse + String refreshToken = authResponse.json.refresh_token + userDetailsManager.deleteUser('foo') + + when: + def response = restBuilder.post("${baseUrl}/oauth/access_token") { + contentType "application/x-www-form-urlencoded" + body "grant_type=refresh_token&refresh_token=${refreshToken}".toString() + } + + then: + response.status == 403 + + cleanup: + userDetailsManager.deleteUser('foo') + } + + @Issue("https://github.com/grails/grails-spring-security-rest/pull/344") + @Unroll + void "if the user is #status, token can't be refreshed"(User updatedUser, String status) { + given: + userDetailsManager.createUser(new User('foo', '{noop}password', [])) + RestResponse authResponse = sendCorrectCredentials('foo', 'password') as RestResponse + String refreshToken = authResponse.json.refresh_token + userDetailsManager.updateUser(updatedUser) + + when: + def response = restBuilder.post("${baseUrl}/oauth/access_token") { + contentType "application/x-www-form-urlencoded" + body "grant_type=refresh_token&refresh_token=${refreshToken}".toString() + } + + then: + response.status == 403 + + cleanup: + userDetailsManager.deleteUser('foo') + + where: + updatedUser | status + new User('foo', '{noop}password', false, true, true, true, []) | "disabled" + new User('foo', '{noop}password', true, false, true, true, []) | "expired" + new User('foo', '{noop}password', true, true, false, true, []) | "credentials expired" + new User('foo', '{noop}password', true, true, true, false, []) | "locked" + } + +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/RestAuthenticationFilterSpec.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/RestAuthenticationFilterSpec.groovy new file mode 100644 index 00000000000..f2f4f7e0099 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/RestAuthenticationFilterSpec.groovy @@ -0,0 +1,111 @@ +/* + * 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 rest + +import spock.lang.IgnoreIf +import spock.lang.Issue +import spock.lang.Unroll + +import grails.plugins.rest.client.RestResponse + +@IgnoreIf({ System.getProperty('useBearerToken', 'false').toBoolean() }) +class RestAuthenticationFilterSpec extends AbstractRestSpec { + + @Unroll + void "#httpMethod requests without parameters/JSON generate #statusCode responses"() { + + when: + def response = sendEmptyRequest(httpMethod) + + then: + response.status == statusCode + + where: + httpMethod | statusCode + 'get' | 405 + 'post' | 400 + 'put' | 405 + 'delete' | 405 + } + + + @Unroll + void "the filter is only applied to the configured URL when a #httpMethod request is sent"() { + when: + def response = restBuilder."${httpMethod}"("${baseUrl}/nothingHere") + + then: + response.status == status + + where: + httpMethod | status + 'get' | 200 //The client follows redirects in GET requests. In this case, to /login/auth + 'post' | 302 //In the rest of the cases, 302 to /login/auth + 'put' | 302 + 'delete' | 302 + + } + + void "authentication attempt with wrong credentials returns a failure status code"() { + when: + def response = sendWrongCredentials() + + then: + response.status == 401 + } + + void "authentication attempt with correct credentials returns a valid status code"() { + when: + RestResponse response = sendCorrectCredentials() as RestResponse + + then: + response.status == 200 + response.json.username == 'jimi' + response.json.access_token + response.json.roles.size() == 2 + } + + void "the content type header is properly set"() { + when: + RestResponse response = sendCorrectCredentials() as RestResponse + + then: + response.headers.get('Content-Type')?.first() == 'application/json;charset=UTF-8' + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/275") + void "WWW-Authenticate response header is sent on failed logins"() { + when: + def response = sendWrongCredentials() + + then: + response.headers.getFirst('WWW-Authenticate') == 'Bearer' + } + + + private sendEmptyRequest(httpMethod) { + if (config.grails.plugin.springsecurity.rest.login.useRequestParamsCredentials == true) { + restBuilder."${httpMethod}"("${baseUrl}/api/login") + } else { + restBuilder."${httpMethod}"("${baseUrl}/api/login") { + json {} + } + } + } +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/RestTokenValidationFilterSpec.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/RestTokenValidationFilterSpec.groovy new file mode 100644 index 00000000000..c8e65c90a09 --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/RestTokenValidationFilterSpec.groovy @@ -0,0 +1,141 @@ +/* + * 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 rest + +import spock.lang.IgnoreIf +import spock.lang.Issue +import spock.lang.Subject + +import grails.plugin.springsecurity.rest.RestTokenValidationFilter +import grails.plugins.rest.client.RestResponse + +@IgnoreIf({ System.getProperty('useBearerToken', 'false').toBoolean() }) +@Subject(RestTokenValidationFilter) +class RestTokenValidationFilterSpec extends AbstractRestSpec { + + void "accessing a secured controller without token returns 403 (anonymous not authorized)"() { + when: + def response = restBuilder.get("${baseUrl}/secured") + + then: + response.status == 403 + } + + void "accessing a secured controller with wrong token, returns 401"() { + when: + def response = restBuilder.get("${baseUrl}/secured") { + header 'X-Auth-Token', 'whatever' + } + + then: + response.status == 401 + + } + + void "accessing a public controller without token returns 302"() { + when: + def response = restBuilder.post("${baseUrl}/public") + + then: + response.status == 302 + } + + void "accessing a public controller with wrong token, returns 302"() { + when: + def response = restBuilder.post("${baseUrl}/public") { + header 'X-Auth-Token', 'whatever' + } + + then: + response.status == 302 + + } + + void "a valid user can access the secured controller"() { + given: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String token = authResponse.json.access_token + + when: + def response = restBuilder.get("${baseUrl}/secured") { + header 'X-Auth-Token', token + } + + then: + response.status == 200 + response.text == 'jimi' + } + + void "role restrictions are applied when user does not have enough credentials"() { + given: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String token = authResponse.json.access_token + + when: + def response = restBuilder.get("${baseUrl}/secured/superAdmin") { + header 'X-Auth-Token', token + } + + then: + response.status == 403 + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/67") + void "JSESSIONID cookie is not created when using the stateless chain"() { + when: + RestResponse authResponse = sendCorrectCredentials() as RestResponse + String token = authResponse.json.access_token + + then: + !authResponse.headers.getFirst('Set-Cookie') + + when: + def response = restBuilder.get("${baseUrl}/secured") { + header 'X-Auth-Token', token + } + + then: + !response.headers.getFirst('Set-Cookie') + + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/74") + void "anonymous access works when enabled"() { + when: + def response = restBuilder.get("${baseUrl}/anonymous") + + then: + response.text == 'Hi' + response.status == 200 + + } + + @Issue("https://github.com/grails/grails-spring-security-rest/issues/74") + void "in an anonymous chain, if a token is sent, is validated"() { + when: + def response = restBuilder.post("${baseUrl}/anonymous") { + header 'X-Auth-Token', 'whatever' + } + + then: + response.status == 401 + + } + +} diff --git a/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/ValidateEndpointSpec.groovy b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/ValidateEndpointSpec.groovy new file mode 100644 index 00000000000..e23c44dbd7d --- /dev/null +++ b/grails-spring-security/rest/testapp-profile/skeleton/src/integration-test/groovy/rest/ValidateEndpointSpec.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 rest + +import spock.lang.IgnoreIf + +import grails.plugins.rest.client.RestResponse + +@IgnoreIf({ System.getProperty('useBearerToken', 'false').toBoolean() }) +class ValidateEndpointSpec extends AbstractRestSpec { + + void "calling /api/validate with a valid token returns a JSON representation"() { + given: + RestResponse authResponse = sendCorrectCredentials() + String token = authResponse.json.access_token + + when: + def response = restBuilder.get("${baseUrl}/api/validate") { + header 'X-Auth-Token', token + } + + then: + response.status == 200 + response.json.username == 'jimi' + response.json.access_token + response.json.roles.size() == 2 + } + + void "calling /api/validate with an invalid token returns 401"() { + when: + def response = restBuilder.get("${baseUrl}/api/validate") { + header 'X-Auth-Token', 'something-else' + } + + then: + response.status == 401 + } + + void "calling /api/validate without token returns 403 (anonymous unauthorized)"() { + when: + def response = restBuilder.get("${baseUrl}/api/validate") + + then: + response.status == 403 + } + + +} \ No newline at end of file diff --git a/grails-spring-security/ui/plugin/build.gradle b/grails-spring-security/ui/plugin/build.gradle new file mode 100644 index 00000000000..3fe237d1e43 --- /dev/null +++ b/grails-spring-security/ui/plugin/build.gradle @@ -0,0 +1,104 @@ +/* + * 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. + */ + +plugins { + id 'groovy' + id 'java-library' + id 'project-report' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.gradle.grails-code-style' + id 'org.apache.grails.gradle.grails-gsp' + id 'org.apache.grails.gradle.grails-plugin' + id 'cloud.wondrify.asset-pipeline' +} + +version = projectVersion +group = 'org.apache.grails' + +dependencies { + + implementation platform(project(':grails-bom')) + + // Must be on the compileClasspath of consuming applications to + // enable compilation of AbstractS2UiDomainController subclasses. + compileOnlyApi project(':grails-converters') + + implementation project(':grails-spring-security') + implementation project(':grails-controllers') + implementation project(':grails-converters') + implementation project(':grails-gsp') + implementation project(':grails-layout') + implementation 'org.springframework.security:spring-security-core' + implementation 'org.springframework.security:spring-security-web' + + // 3rd party client-side assets bundled in webjars + implementation 'org.webjars:datatables:1.10.25' + implementation 'org.webjars:jquery:2.1.4' + implementation 'org.webjars:jquery-form:3.51' + implementation 'org.webjars:jquery-ui:1.10.3' + implementation 'org.webjars:jquery-ui-themes:1.10.3' + implementation 'org.webjars.bower:bgiframe:3.0.1' + implementation 'org.webjars.bower:jgrowl:1.4.6' + + compileOnly project(':grails-core') // Provided, as this is a Grails plugin + compileOnly project(':grails-services') + compileOnly project(':grails-domain-class') + + runtimeOnly 'cloud.wondrify:asset-pipeline-grails' + + testImplementation project(':grails-testing-support-web') + testImplementation 'org.spockframework:spock-core' +} + +tasks.register('copyGspIntoTemplates', Copy) { + from(layout.projectDirectory.dir('grails-app/views')) + into(layout.projectDirectory.dir('src/main/templates/views')) + dependsOn('copyTemplates', 'processResources') +} + +tasks.register('removeGspFromTemplates', Delete) { + delete(layout.projectDirectory.dir('src/main/templates/views')) +} + +tasks.named('compileAstJava') { + dependsOn('copyGspIntoTemplates') +} + +assets { + packagePlugin = true + excludes = ['webjars/**'] +} + +// Package the canonical license texts referenced by META-INF/LICENSE +tasks.withType(Jar).configureEach { jarTask -> + jarTask.into('META-INF/licenses') { + from rootProject.layout.projectDirectory.file('licenses/LICENSE-MIT.txt') + include { jarTask.archiveClassifier.getOrElse('') != 'javadoc' } + } +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/spring-security-test-config.gradle') +} diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/icons/error.svg b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/error.svg new file mode 100644 index 00000000000..94c469e9b94 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/icons/info.svg b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/info.svg new file mode 100644 index 00000000000..a85b0ad0fbc --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/icons/role.svg b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/role.svg new file mode 100644 index 00000000000..c780cb205d4 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/role.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/icons/user.svg b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/user.svg new file mode 100644 index 00000000000..7adbc4ea9f6 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/icons/users.svg b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/users.svg new file mode 100644 index 00000000000..b0d5700994d --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/images/icons/users.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/sort_asc.png b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_asc.png new file mode 100644 index 00000000000..e1ba61a8055 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_asc.png differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/sort_asc_disabled.png b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_asc_disabled.png new file mode 100644 index 00000000000..fb11dfe24a6 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_asc_disabled.png differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/sort_both.png b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_both.png new file mode 100644 index 00000000000..af5bc7c5a10 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_both.png differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/sort_desc.png b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_desc.png new file mode 100644 index 00000000000..0e156deb5f6 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_desc.png differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/images/sort_desc_disabled.png b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_desc_disabled.png new file mode 100644 index 00000000000..c9fdd8a1502 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/images/sort_desc_disabled.png differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/javascripts/jquery/jquery.jdMenu.js b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/jquery/jquery.jdMenu.js new file mode 100644 index 00000000000..3b59ceda87d --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/jquery/jquery.jdMenu.js @@ -0,0 +1,171 @@ +/* + * jdMenu 1.4.1 (2008-03-31) + * + * Copyright (c) 2006,2007 Jonathan Sharp (http://jdsharp.us) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://jdsharp.us/ + * + * Built upon jQuery 1.2.1 (http://jquery.com) + * This also requires the jQuery dimensions >= 1.2 plugin + */ + +// This initializes the menu +$(function() { + $('ul.jd_menu').jdMenu(); +}); + +(function($){ + function addEvents(ul) { + var settings = $.data( $(ul).parents().andSelf().filter('ul.jd_menu')[0], 'jdMenuSettings' ); + $('> li', ul) + .bind('mouseenter.jdmenu mouseleave.jdmenu', function(evt) { + $(this).toggleClass('jdm_hover'); + var ul = $('> ul', this); + if ( ul.length == 1 ) { + clearTimeout( this.$jdTimer ); + var enter = ( evt.type == 'mouseenter' ); + var fn = ( enter ? showMenu : hideMenu ); + this.$jdTimer = setTimeout(function() { + fn( ul[0], settings.onAnimate, settings.isVertical ); + }, enter ? settings.showDelay : settings.hideDelay ); + } + }) + .bind('click.jdmenu', function(evt) { + var ul = $('> ul', this); + if ( ul.length == 1 && + ( settings.disableLinks == true || $(this).hasClass('accessible') ) ) { + showMenu( ul, settings.onAnimate, settings.isVertical ); + return false; + } + + // The user clicked the li and we need to trigger a click for the a + if ( evt.target == this ) { + var link = $('> a', evt.target).not('.accessible'); + if ( link.length > 0 ) { + var a = link[0]; + if ( !a.onclick ) { + window.open( a.href, a.target || '_self' ); + } else { + $(a).trigger('click'); + } + } + } + if ( settings.disableLinks || + ( !settings.disableLinks && !$(this).parent().hasClass('jd_menu') ) ) { + $(this).parent().jdMenuHide(); + evt.stopPropagation(); + } + }) + .find('> a') + .bind('focus.jdmenu blur.jdmenu', function(evt) { + var p = $(this).parents('li:eq(0)'); + if ( evt.type == 'focus' ) { + p.addClass('jdm_hover'); + } else { + p.removeClass('jdm_hover'); + } + }) + .filter('.accessible') + .bind('click.jdmenu', function(evt) { + evt.preventDefault(); + }); + } + + function showMenu(ul, animate, vertical) { + var ul = $(ul); + if ( ul.is(':visible') ) { + return; + } + ul.bgiframe(); + var li = ul.parent(); + ul .trigger('jdMenuShow') + .positionBy({ target: li[0], + targetPos: ( vertical === true || !li.parent().hasClass('jd_menu') ? 1 : 3 ), + elementPos: 0, + hideAfterPosition: true + }); + if ( !ul.hasClass('jdm_events') ) { + ul.addClass('jdm_events'); + addEvents(ul); + } + li .addClass('jdm_active') + // Hide any adjacent menus + .siblings('li').find('> ul:eq(0):visible') + .each(function(){ + hideMenu( this ); + }); + if ( animate === undefined ) { + ul.show(); + } else { + animate.apply( ul[0], [true] ); + } + } + + function hideMenu(ul, animate) { + var ul = $(ul); + $('.bgiframe', ul).remove(); + ul .filter(':not(.jd_menu)') + .find('> li > ul:eq(0):visible') + .each(function() { + hideMenu( this ); + }) + .end(); + if ( animate === undefined ) { + ul.hide() + } else { + animate.apply( ul[0], [false] ); + } + + ul .trigger('jdMenuHide') + .parents('li:eq(0)') + .removeClass('jdm_active jdm_hover') + .end() + .find('> li') + .removeClass('jdm_active jdm_hover'); + } + + // Public methods + $.fn.jdMenu = function(settings) { + // Future settings: activateDelay + var settings = $.extend({ // Time in ms before menu shows + showDelay: 200, + // Time in ms before menu hides + hideDelay: 500, + // Should items that contain submenus not + // respond to clicks + disableLinks: true + // This callback allows for you to animate menus + //onAnimate: null + }, settings); + if ( !$.isFunction( settings.onAnimate ) ) { + settings.onAnimate = undefined; + } + return this.filter('ul.jd_menu').each(function() { + $.data( this, + 'jdMenuSettings', + $.extend({ isVertical: $(this).hasClass('jd_menu_vertical') }, settings) + ); + addEvents(this); + }); + }; + + $.fn.jdMenuUnbind = function() { + $('ul.jdm_events', this) + .unbind('.jdmenu') + .find('> a').unbind('.jdmenu'); + }; + $.fn.jdMenuHide = function() { + return this.filter('ul').each(function(){ + hideMenu( this ); + }); + }; + + // Private methods and logic + $(window) + // Bind a click event to hide all visible menus when the document is clicked + .bind('click.jdmenu', function(){ + $('ul.jd_menu ul:visible').jdMenuHide(); + }); +})(jQuery); diff --git a/grails-spring-security/ui/plugin/grails-app/assets/javascripts/jquery/jquery.positionBy.js b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/jquery/jquery.positionBy.js new file mode 100644 index 00000000000..23dd1148ebb --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/jquery/jquery.positionBy.js @@ -0,0 +1,306 @@ +/* + * positionBy 1.0.7 (2008-01-29) + * + * Copyright (c) 2006,2007 Jonathan Sharp (http://jdsharp.us) + * Dual licensed under the MIT (MIT-LICENSE.txt) + * and GPL (GPL-LICENSE.txt) licenses. + * + * http://jdsharp.us/ + * + * Built upon jQuery 1.2.2 (http://jquery.com) + * This also requires the jQuery dimensions plugin + */ +(function($){ + /** + * This function centers an absolutely positioned element + */ + /* + $.fn.positionCenter = function(offsetLeft, offsetTop) { + var offsetLeft = offsetLeft || 1; + var offsetTop = offsetTop || 1; + + var ww = $(window).width(); + var wh = $(window).height(); + var sl = $(window).scrollLeft(); + var st = $(window).scrollTop(); + + return this.each(function() { + var $t = $(this); + + // If we are not visible we have to display our element (with a negative position offscreen) + + var left = Math.round( ( ww - $t.outerWidth() ) / 2 ); + if ( left < 0 ) { + left = 0; + } else { + left *= offsetLeft; + } + left += sl; + var top = Math.round( ( wh - $t.outerHeight() ) / 2 ); + if ( top < 0 ) { + top = 0; + } else { + top *= offsetTop; + } + top += st; + + $(this).parents().each(function() { + var $this = $(this); + if ( $this.css('position') != 'static' ) { + var o = $this.offset(); + left += -o.left; + top += -o.top; + return false; + } + }); + + $t.css({left: left, top: top}); + }); + }; + */ + + // Our range object is used in calculating positions + var Range = function(x1, y1, x2, y2) { + this.x1 = x1; this.x2 = x2; + this.y1 = y1; this.y2 = y2; + }; + Range.prototype.contains = function(range) { + return (this.x1 <= range.x1 && range.x2 <= this.x2) + && + (this.y1 <= range.y1 && range.y2 <= this.y2); + }; + Range.prototype.transform = function(x, y) { + return new Range(this.x1 + x, this.y1 + y, this.x2 + x, this.y2 + y); + }; + + $.fn.positionBy = function(args) { + var date1 = new Date(); + if ( this.length == 0 ) { + return this; + } + + var args = $.extend({ // The target element to position us relative to + target: null, + // The target's corner, possible values 0-3 + targetPos: null, + // The element's corner, possible values 0-3 + elementPos: null, + + // A raw x,y coordinate + x: null, + y: null, + + // Pass in an array of positions that are valid 0-15 + positions: null, + + // Add the final position class to the element (eg. positionBy0 through positionBy3, positionBy15) + addClass: false, + + // Force our element to be at the location we specified (don't try to auto position it) + force: false, + + // The element that we will make sure our element doesn't go outside of + container: window, + + // Should the element be hidden after positioning? + hideAfterPosition: false + }, args); + + if ( args.x != null ) { + var tLeft = args.x; + var tTop = args.y; + var tWidth = 0; + var tHeight = 0; + + // Position in relation to an element + } else { + var $target = $( $( args.target )[0] ); + var tWidth = $target.outerWidth(); + var tHeight = $target.outerHeight(); + var tOffset = $target.offset(); + var tLeft = tOffset.left; + var tTop = tOffset.top; + } + + // Our target right, bottom coord + var tRight = tLeft + tWidth; + var tBottom = tTop + tHeight; + + return this.each(function() { + var $element = $( this ); + + // Position our element in the top left so we can grab its width without triggering scrollbars + if ( !$element.is(':visible') ) { + $element.css({ left: -3000, + top: -3000 + }) + .show(); + } + + var eWidth = $element.outerWidth(); + var eHeight = $element.outerHeight(); + + // Holds x1,y1,x2,y2 coordinates for a position in relation to our target element + var position = []; + // Holds a list of alternate positions to try if this one is not in the browser viewport + var next = []; + + // Our Positions via ASCII ART + /* + 8 9 10 11 + +------------+ + 7 | 15 12 | 0 + | | + 6 | 14 13 | 1 + +------------+ + 5 4 3 2 + + */ + + position[0] = new Range(tRight, tTop, tRight + eWidth, tTop + eHeight); + next[0] = [1,7,4]; + + position[1] = new Range(tRight, tBottom - eHeight, tRight + eWidth, tBottom); + next[1] = [0,6,4]; + + position[2] = new Range(tRight, tBottom, tRight + eWidth, tBottom + eHeight); + next[2] = [1,3,10]; + + position[3] = new Range(tRight - eWidth, tBottom, tRight, tBottom + eHeight); + next[3] = [1,6,10]; + + position[4] = new Range(tLeft, tBottom, tLeft + eWidth, tBottom + eHeight); + next[4] = [1,6,9]; + + position[5] = new Range(tLeft - eWidth, tBottom, tLeft, tBottom + eHeight); + next[5] = [6,4,9]; + + position[6] = new Range(tLeft - eWidth, tBottom - eHeight, tLeft, tBottom); + next[6] = [7,1,4]; + + position[7] = new Range(tLeft - eWidth, tTop, tLeft, tTop + eHeight); + next[7] = [6,0,4]; + + position[8] = new Range(tLeft - eWidth, tTop - eHeight, tLeft, tTop); + next[8] = [7,9,4]; + + position[9] = new Range(tLeft, tTop - eHeight, tLeft + eWidth, tTop); + next[9] = [0,7,4]; + + position[10]= new Range(tRight - eWidth, tTop - eHeight, tRight, tTop); + next[10] = [0,7,3]; + + position[11]= new Range(tRight, tTop - eHeight, tRight + eWidth, tTop); + next[11] = [0,10,3]; + + position[12]= new Range(tRight - eWidth, tTop, tRight, tTop + eHeight); + next[12] = [13,7,10]; + + position[13]= new Range(tRight - eWidth, tBottom - eHeight, tRight, tBottom); + next[13] = [12,6,3]; + + position[14]= new Range(tLeft, tBottom - eHeight, tLeft + eWidth, tBottom); + next[14] = [15,1,4]; + + position[15]= new Range(tLeft, tTop, tLeft + eWidth, tTop + eHeight); + next[15] = [14,0,9]; + + if ( args.positions !== null ) { + var pos = args.positions[0]; + } else if ( args.targetPos != null && args.elementPos != null ) { + var pos = []; + pos[0] = []; + pos[0][0] = 15; + pos[0][1] = 7; + pos[0][2] = 8; + pos[0][3] = 9; + pos[1] = []; + pos[1][0] = 0; + pos[1][1] = 12; + pos[1][2] = 10; + pos[1][3] = 11; + pos[2] = []; + pos[2][0] = 2; + pos[2][1] = 3; + pos[2][2] = 13; + pos[2][3] = 1; + pos[3] = []; + pos[3][0] = 4; + pos[3][1] = 5; + pos[3][2] = 6; + pos[3][3] = 14; + + var pos = pos[args.targetPos][args.elementPos]; + } + var ePos = position[pos]; + var fPos = pos; + + if ( !args.force ) { + // TODO: Do the args.container + // window width & scroll offset + $window = $( window ); + var sx = $window.scrollLeft(); + var sy = $window.scrollTop(); + + // TODO: Look at innerWidth & innerHeight + var container = new Range( sx, sy, sx + $window.width(), sy + $window.height() ); + + // If we are outside of our viewport, see if we are outside vertically or horizontally and push onto the stack + var stack; + if ( args.positions ) { + stack = args.positions; + } else { + stack = [pos]; + } + var test = []; // Keeps track of our positions we already tried + + while ( stack.length > 0 ) { + var p = stack.shift(); + if ( test[p] ) { + continue; + } + test[p] = true; + + // If our current position is not within the viewport (eg. window) + // add the next suggested position + if ( !container.contains(position[p]) ) { + if ( args.positions === null ) { + stack = jQuery.merge( stack, next[p] ); + } + } else { + ePos = position[p]; + break; + } + } + } + + // + TODO: Determine if we are going to use absolute left, top, bottom, right + // positions relative to our target + + // Take into account any absolute or fixed positioning + // to 'normalize' our coordinates + $element.parents().each(function() { + var $this = $(this); + if ( $this.css('position') != 'static' ) { + var abs = $this.offset(); + ePos = ePos.transform( -abs.left, -abs.top ); + return false; + } + }); + + // Finally position our element + var css = { left: ePos.x1, top: ePos.y1 }; + if ( args.hideAfterPosition ) { + css['display'] = 'none'; + } + $element.css( css ); + + if ( args.addClass ) { + $element.removeClass( 'positionBy0 positionBy1 positionBy2 positionBy3 positionBy4 positionBy5 ' + + 'positionBy6 positionBy7 positionBy8 positionBy9 positionBy10 positionBy11 ' + + 'positionBy12 positionBy13 positionBy14 positionBy15') + .addClass('positionBy' + p); + } + }); + }; +})(jQuery); diff --git a/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui-ajaxLogin.js b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui-ajaxLogin.js new file mode 100644 index 00000000000..38fbad7ec89 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui-ajaxLogin.js @@ -0,0 +1,89 @@ +/* + * 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. + */ + +$(function() { + var buttons = {}; + buttons[cancelButtonCaption] = function() { + $(this).dialog('close'); + }; + buttons[loginButtonCaption] = function() { + $('#loginForm').submit(); + }; + + $("#loginFormContainer").dialog({ + autoOpen: false, + height: 450, + width: 450, + modal: true, + buttons: buttons + }); + + $('#loginForm').bind('submit', function() { + $(this).ajaxSubmit({ + target: '#loginMessage', + beforeSubmit: function() { + $('#loginMessage').html(loggingYouIn); + return true; + }, + success: function(json) { + if (json.success) { + if (json.url) { + document.location = json.url; + } + else { + $('#loginFormContainer').dialog('close'); + $('#loginLinkContainer').html( + loggedInAsWithPlaceholder.replace(/\{0\}/, json.username) + + ' (Logout)'); + $("#logout").click(logout); + } + } + else if (json.error) { + $('#loginMessage').html("" + json.error + ''); + } + else { + $('#loginMessage').html(responseText); + } + }, + dataType: 'json' + }); + return false; + }); + + $('#loginLink').click(function() { + $('#loginFormContainer').show().dialog('open'); + $('#ajaxUsername').focus(); + }); + + $("#logout").click(logout); +}); + +function logout(event) { + event.preventDefault(); + $.ajax({ + url: $("#_logout").attr("href"), + method: "post", + success: function(data, textStatus, jqXHR) { + window.location = $("#_afterLogout").attr("href"); + }, + error: function(jqXHR, textStatus, errorThrown) { + console.log("Logout error, textStatus: " + textStatus + ", errorThrown: " + errorThrown); + } + }); +} diff --git a/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui-register.js b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui-register.js new file mode 100644 index 00000000000..531680f51d9 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui-register.js @@ -0,0 +1,22 @@ +/* + * 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. + */ + +//= require webjars/jquery/2.1.4/jquery.min.js +//= require webjars/jquery-ui/1.10.3/ui/minified/jquery-ui.min.js +//= require webjars/jgrowl/1.4.6/jquery.jgrowl.min.js diff --git a/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui.js b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui.js new file mode 100644 index 00000000000..26b0bb25aed --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/javascripts/spring-security-ui.js @@ -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. + */ + +//= require webjars/jquery/2.1.4/jquery.min.js +//= require webjars/jquery-ui/1.10.3/ui/minified/jquery-ui.min.js +//= require webjars/jgrowl/1.4.6/jquery.jgrowl.min.js +//= require jquery/jquery.positionBy.js +//= require webjars/bgiframe/3.0.1/jquery.bgiframe.js +//= require webjars/jquery-form/3.51/jquery.form.js +//= require jquery/jquery.jdMenu.js +//= require spring-security-ui-ajaxLogin.js diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/btn_left.gif b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/btn_left.gif new file mode 100644 index 00000000000..9614ed2c13c Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/btn_left.gif differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/btn_right.gif b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/btn_right.gif new file mode 100644 index 00000000000..4616184e6b6 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/btn_right.gif differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/checkbox.gif b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/checkbox.gif new file mode 100644 index 00000000000..8032eb671f2 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/checkbox.gif differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input.gif b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input.gif new file mode 100644 index 00000000000..d58924dc321 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input.gif differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input_left.gif b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input_left.gif new file mode 100644 index 00000000000..54bb1301736 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input_left.gif differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input_right.gif b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input_right.gif new file mode 100644 index 00000000000..726ea0ecb42 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/input_right.gif differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/radio.gif b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/radio.gif new file mode 100644 index 00000000000..400d4e54246 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/radio.gif differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/select_right.gif b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/select_right.gif new file mode 100644 index 00000000000..ff2355f8753 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/elements/select_right.gif differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient-alt.png b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient-alt.png new file mode 100644 index 00000000000..04dae6d0cb6 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient-alt.png differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient-vertical.png b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient-vertical.png new file mode 100644 index 00000000000..eef91896034 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient-vertical.png differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient.png b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient.png new file mode 100644 index 00000000000..88c46d2cab6 Binary files /dev/null and b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/gradient.png differ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery-ui.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery-ui.css new file mode 100644 index 00000000000..3e9ff224ae5 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery-ui.css @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/* + *= require webjars/jquery-ui-themes/1.10.3/smoothness/jquery-ui + */ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.dataTables.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.dataTables.css new file mode 100644 index 00000000000..fbeedcc8f7b --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.dataTables.css @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/* + *= require webjars/datatables/1.10.25/css/jquery.dataTables + */ \ No newline at end of file diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.jdMenu.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.jdMenu.css new file mode 100644 index 00000000000..1f5d84530bc --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.jdMenu.css @@ -0,0 +1,90 @@ +ul.jd_menu, +ul.jd_menu_vertical { + margin: 0px; + padding: 0px; + list-style-type: none; +} +ul.jd_menu ul, +ul.jd_menu_vertical ul { + display: none; +} +ul.jd_menu li { + float: left; +} +ul.jd_menu_vertical { + width: 300px; +} +ul.jd_menu_vertical li { + float: none; +} + +/* -- Sub-Menus -- */ +ul.jd_menu ul, +ul.jd_menu_vertical ul { + position: absolute; + display: none; + list-style-type: none; + margin: 0px; + padding: 0px; + z-index: 10000; +} +ul.jd_menu ul li, +ul.jd_menu_vertical ul li { + float: none; + margin: 0px; +} + + +ul.jd_menu, +ul.jd_menu ul, +ul.jd_menu_vertical, +ul.jd_menu_vertical ul { + background-color: #369; + border: 1px solid #036; + border-top: 1px solid #69C; + border-left: 1px solid #69C; + + height: 20px; +} +ul.jd_menu_vertical { + height: auto; +} +ul.jd_menu ul { + height: auto; +} +* html ul.jd_menu ul { + width: 1%; +} +ul.jd_menu li { + font-family: sans-serif; + font-size: 12px; + color: #FFF; + + line-height: 14px; + + margin: 0px; + padding: 4px 7px 3px 7px; + height: 13px; + + cursor: pointer; + white-space: nowrap; +} +ul.jd_menu li li { + width: 200px; +} +ul.jd_menu li a { + color: #FFF; + text-decoration: none; +} + +ul.jd_menu li.jdm_hover, +ul.jd_menu li.jdm_active { + background-color: #69C; + color: #FFF; + + padding: 3px 6px 2px 6px; + border: 1px solid #369; + border-left: 1px solid #9CF; + border-top: 1px solid #9CF; + +} diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.jdMenu.slate.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.jdMenu.slate.css new file mode 100644 index 00000000000..3f3e822ddc9 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/jquery.jdMenu.slate.css @@ -0,0 +1,83 @@ +ul.jd_menu_slate { + height: 19px; + background-color: #DDF; + background: url(gradient.png) repeat-x; + border: 1px solid #70777D; + border-top: 1px solid #A5AFB8; + border-left: 1px solid #A5AFB8; + clear: both; +} + +ul.jd_menu_vertical { + width: 200px; + height: auto; + clear: both; + background: url(gradient-vertical.png) repeat-x; + background-color: #A5AFB8; +} + + +ul.jd_menu_slate a, +ul.jd_menu_slate a:active, +ul.jd_menu_slate a:link, +ul.jd_menu_slate a:visited { + text-decoration: none; + color: #FFF; +} +ul.jd_menu_slate ul li a, +ul.jd_menu_slate ul li a:active, +ul.jd_menu_slate ul li a:link, +ul.jd_menu_slate ul li a:visited { + color: #70777D; +} +ul.jd_menu_slate li { + font-family: Tahoma, sans-serif; + font-size: 11px; + padding: 2px 6px 4px 6px; + cursor: pointer; + white-space: nowrap; + color: #FFF; +} +ul.jd_menu_slate li.jdm_active, +ul.jd_menu_slate li.jdm_hover { + padding-left: 5px; + border-left: 1px solid #ABB5BC; + padding-right: 5px; + border-right: 1px solid #929AA1; + border-right: 1px solid #70777D; + color: #FFF; + background: url(gradient-alt.png) repeat-x; +} + +ul.jd_menu_vertical li.jdm_active, +ul.jd_menu_vertical li.jdm_hover { + padding-left: 6px; + padding-top: 1px; + border-top: 1px solid #70777D; + border-left: 0px; + border-right: 0px; +} + +ul.jd_menu_slate ul { + background: #ABB5BC; + border: 1px solid #70777D; +} +ul.jd_menu_slate ul li { + padding: 3px 10px 3px 4px; + background: #E6E6E6; + border: none; + color: #70777D; +} +ul.jd_menu_slate ul li.jd_menu_active, +ul.jd_menu_slate ul li.jd_menu_hover { + background: url(gradient.png) repeat-x; + padding-top: 2px; + border-top: 1px solid #ABB5BC; + padding-bottom: 2px; + border-bottom: 1px solid #929AA1; + color: #FFF; +} +ul.jd_menu_slate ul li.jd_menu_active a.jd_menu_active, +ul.jd_menu_slate ul li.jd_menu_hover a.jd_menu_hover { + color: #FFF; +} diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/reset.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/reset.css new file mode 100644 index 00000000000..2254ab357eb --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/reset.css @@ -0,0 +1,69 @@ +/* + * 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. + */ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, font, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td { + margin: 0; + padding: 0; + border: 0; + outline: 0; + font-size: 100%; + vertical-align: baseline; + background: transparent; +} +body { + line-height: 1; +} +ol, ul { + list-style: none; +} +blockquote, q { + quotes: none; +} + +/* remember to define focus styles! */ +:focus { + outline: 0; +} + +/* remove textarea resize at Safari */ +textarea { + resize: none; +} + +/* remember to highlight inserts somehow! */ +ins { + text-decoration: none; +} +del { + text-decoration: line-through; +} + +/* tables still need 'cellspacing="0"' in the markup */ +table { + border-collapse: collapse; + border-spacing: 0; +} \ No newline at end of file diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-auth.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-auth.css new file mode 100644 index 00000000000..a701e440d33 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-auth.css @@ -0,0 +1,43 @@ +/* + * 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. + */ + +.login { + background-color: #F1F1F1; + margin-bottom: 15px; + overflow: hidden; + width: 550px; + height: 350px; +} + +.login-inner { + color: #9B9B9B; + padding: 16px; +} + +.login-inner .sign-in h1 { + color: #9B9B9B; + font-size: 16px; + font-weight: bold; + margin-bottom: 5px; + padding-bottom: 0; +} + +input.formLogin { + width: 300px; +} diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-common.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-common.css new file mode 100644 index 00000000000..47a0669e111 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-common.css @@ -0,0 +1,304 @@ +/* + * 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. + */ + +/* standard tags */ +a { + color: #363636; + cursor: pointer; + text-decoration: none; +} + +a:hover { + color: #f30; + text-decoration: underline; +} + +a:link, a:visited { + color: #039; +} + +body { + background: #fff; + color: #333; + line-height: 1.5; + margin-left: 0px; + margin-right: 0px; + min-width: 980px; +} + +body, input, select, textarea { + font-family: Tahoma, verdana, arial, helvetica, sans-serif; + font-size: 0.9em; +} + +caption { + font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + font-weight: bold; + text-align: center; + font-size: 1.5em; + padding: 20px 0; +} + +h3 { + color: #222222; + font-size: 1.2em; + font-weight: bold; + line-height: 1; + margin-bottom: 1em; + text-align: center; +} + +input[type=checkbox], input[type=radio] { + height: 20px; + width: 20px; +} + +ol, ul { + list-style: none; +} + +table { + border-collapse: separate; + border-spacing: 0; + margin-bottom: 1.4em; + vertical-align: middle; +} + +table.info { + width: 100%; +} + +table.info thead th { + background: #444; + color: white; +} + +td { + font-weight: normal; +} + +td, th { + padding: 8px 10px 8px 5px; + text-align: left; + vertical-align: middle; +} + +th { + font-weight: bold; +} + +thead th { + background: #999; +} + +tr.even td { + background: #ccc; +} + +tr.odd td { + background: white; +} + +/* DataTables - including here so asset-pipeline updates the paths correctly */ +table.dataTable thead .sorting { + background-image: url("../images/sort_both.png"); +} +table.dataTable thead .sorting_asc { + background-image: url("../images/sort_asc.png"); +} +table.dataTable thead .sorting_asc_disabled { + background-image: url("../images/sort_asc_disabled.png"); +} +table.dataTable thead .sorting_desc { + background-image: url("../images/sort_desc.png"); +} +table.dataTable thead .sorting_desc_disabled { + background-image: url("../images/sort_desc_disabled.png"); +} + +/* jdMenu overrides */ +ul.jd_menu, +ul.jd_menu ul { + height: 26px; +} + +ul.jd_menu li { + font-family: Tahoma, verdana, arial, helvetica, sans-serif; + font-size: 1em; + height: 20px; + line-height: 15px; +} + +ul.jd_menu li, +ul.jd_menu li a, +ul.jd_menu li.jdm_active, +ul.jd_menu li.jdm_hover { + color: #222; +} + +ul.jd_menu li li { + width: 300px; +} + +/* jGrowl overrides */ +.jGrowl { + font-family: Tahoma, verdana, arial, helvetica, sans-serif; + font-size: 1em; +} + +.jGrowl-closer { + font-size: 1.1em; +} + +.jGrowl-notification { + min-height: 60px; +} + +.jGrowl-notification .jGrowl-header { + font-size: 1.25em; +} + +/* Spring Security UI */ + +.s2ui_center { + margin-left: auto; + margin-right: auto; +} + +.s2ui_error { + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + color: #D8000C; + font-weight: normal; +} + +.s2ui_error p { + margin: 2px; +} + +.s2ui_form { + height: 450px; + padding: 5px; + position: relative; + text-align: center; +} + +.s2ui_hidden_button { + background: white; + border: 0; + float: right; + height: 0; + left: 1px; + margin: 0; + padding: 0; + position: absolute; + width: 0; + z-index: -1000; +} + +.s2ui_required { + color: red; + font-weight: bold; +} + +.s2ui_section { + border: 1px solid #f2f2f2; + padding: 18px; +} + +#s2ui_content { + float: left; + margin-left: 12px; + margin-top: 6px; + width: 96%; +} + +#s2ui_header_body { + width: 100%; +} + +#s2ui_header_title { + color: black; + font-size: 1.5em; + margin-top: 5px; + text-align: center; +} + +#s2ui_login_link_container { + float: right; + position: absolute; + right: 10px; + top: 28px; +} + +#s2ui_main { + margin: 0px; + width: 100%; +} + +/* pagination */ + +.paginateButtons { + color: #666; + font-family: Tahoma, verdana, arial, helvetica, sans-serif; + font-size: 0.9em; + overflow: hidden; + padding: 10px 3px; +} + +.paginateButtons a { + background: #fff; + border-color: #ccc #aaa #aaa #ccc; + border: 1px solid #ccc; + color: #666; + margin: 0 3px; + padding: 2px 6px; +} + +.paginateButtons span { + padding: 2px 3px; +} + +/* tab icons */ + +.icon, .ui-tabs .ui-tabs-nav li a.icon { + background-repeat: no-repeat; + padding-left: 24px; + background-position: 4px center; +} + +.icon_error { + background-image: url('icons/error.svg'); +} + +.icon_info { + background-image: url('icons/info.svg'); +} + +.icon_role { + background-image: url('icons/role.svg'); +} + +.icon_user { + background-image: url('icons/user.svg'); +} + +.icon_users { + background-image: url('icons/users.svg'); +} diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-register.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-register.css new file mode 100644 index 00000000000..7fce70305ef --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui-register.css @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/* + *= require reset.css + *= require jquery-ui.css + *= require webjars/jgrowl/1.4.6/jquery.jgrowl.css + *= require spring-security-ui-common.css + */ diff --git a/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui.css b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui.css new file mode 100644 index 00000000000..01a3d79f865 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/assets/stylesheets/spring-security-ui.css @@ -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. + */ + +/* + *= require reset + *= require webjars/jquery-ui-themes/1.10.3/smoothness/jquery-ui + *= require jquery.jdMenu + *= require jquery.jdMenu.slate + *= require webjars/jgrowl/1.4.6/jquery.jgrowl + *= require spring-security-ui-common + */ diff --git a/grails-spring-security/ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy b/grails-spring-security/ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy new file mode 100644 index 00000000000..138f81f3b1a --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/conf/DefaultUiSecurityConfig.groovy @@ -0,0 +1,46 @@ +/* + * 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. + */ +security { + + ui { + + encodePassword = false + + forgotPassword { + emailFrom = 'Do Not Reply ' + postResetUrl = null // use defaultTargetUrl if not set + validationUserLookUpProperty = 'user' + requireForgotPassEmailValidation = 'true' + } + + gsp { + layoutRegister = 'register' + layoutUi = 'springSecurityUI' + } + + register { + defaultRoleNames = ['ROLE_USER'] + emailFrom = 'Do Not Reply ' + postRegisterUrl = null // use defaultTargetUrl if not set + requireEmailValidation = 'true' //set this to false if you don't want an e-mail validation + } + + switchUserRoleName = 'ROLE_SWITCH_USER' + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/conf/application.yml b/grails-spring-security/ui/plugin/grails-app/conf/application.yml new file mode 100644 index 00000000000..15032a0664d --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/conf/application.yml @@ -0,0 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +info: + app: + name: '@info.app.name@' + version: '@info.app.version@' + grailsVersion: '@info.app.grailsVersion@' diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AbstractS2UiController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AbstractS2UiController.groovy new file mode 100644 index 00000000000..f9a2d41c72b --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AbstractS2UiController.groovy @@ -0,0 +1,70 @@ +/* + * 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.ui + +import org.springframework.beans.factory.InitializingBean +import org.springframework.beans.factory.annotation.Autowired + +import grails.core.GrailsApplication +import grails.plugin.springsecurity.SpringSecurityUtils + +/** + * @author Burt Beckwith + */ +abstract class AbstractS2UiController implements InitializingBean { + + static scope = 'singleton' + + // needed for afterPropertiesSet since accessing the Trait's + // grailsApplication looks for a current thread-bound request + private @Autowired + GrailsApplication application + + protected findUserByUsername(String username) { + if (username) { + return User.findWhere((usernamePropertyName): username) + } + } + + protected Class getDomainClassClass(String name) { + if (name) { + return application.getDomainClass(name)?.clazz + } + } + + protected static getConf() { + SpringSecurityUtils.securityConfig + } + + protected String authorityNameField + protected String usernamePropertyName + + protected Class Role + protected Class User + protected Class UserRole + + void afterPropertiesSet() { + authorityNameField = conf.authority.nameField ?: '' + usernamePropertyName = conf.userLookup.usernamePropertyName ?: '' + + Role = getDomainClassClass(conf.authority.className ?: '') + User = getDomainClassClass(conf.userLookup.userDomainClassName ?: '') + UserRole = getDomainClassClass(conf.userLookup.authorityJoinClassName ?: '') + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AbstractS2UiDomainController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AbstractS2UiDomainController.groovy new file mode 100644 index 00000000000..fb15589ca41 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AbstractS2UiDomainController.groovy @@ -0,0 +1,427 @@ +/* + * 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.ui + +import groovy.util.logging.Slf4j + +import org.springframework.dao.DataIntegrityViolationException + +import grails.converters.JSON +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.ui.strategy.AclStrategy +import grails.plugin.springsecurity.ui.strategy.PropertiesStrategy +import grails.plugin.springsecurity.ui.strategy.QueryStrategy + +/** + * @author Burt Beckwith + */ +@Slf4j +abstract class AbstractS2UiDomainController extends AbstractS2UiController { + + static allowedMethods = [save: 'POST', update: 'POST', delete: 'POST'] + static defaultAction = 'search' + + /** Dependency injection for the 'uiAclStrategy' bean. */ + AclStrategy uiAclStrategy + + /** Dependency injection for the 'uiPropertiesStrategy' bean. */ + PropertiesStrategy uiPropertiesStrategy + + /** Dependency injection for the 'uiQueryStrategy' bean. */ + QueryStrategy uiQueryStrategy + + def create() { + doCreate() + } + + protected doCreate() { + model fromParams(), 'create' + } + + protected doSave(instance, Closure afterSave = null) { + + if (instance.hasErrors()) { + renderCreate model(instance, 'save') + return + } + + if (afterSave) { + afterSave() + } + + flashCreated instance.id + redirectToEdit instance.id + } + + protected doSaveWithInvalidToken(String flashArg = '') { + if (!flashArg) { + flashArg = "${message(code: 'spring.security.ui.invalid.form.default.arg')}" + } + response.status = 500 + log.warn('User: {} possible CSRF or double submit: {}', SpringSecurityUtils.authentication.principal.id as String, params as String) + flash.message = "${message(code: 'spring.security.ui.invalid.save.form', args: [flashArg])}" + redirect action: 'create' + } + + protected void renderCreate(Map model) { + render view: 'create', model: model + } + + def edit() { + doEdit() + } + + protected doEdit() { + def instance = lookupFromParams() + if (!instance) return + + model instance, 'edit' + } + + protected doUpdate(Closure update) { + def instance = lookupFromParams() + if (!instance || !versionCheck(instance)) { + return + } + + update instance + + if (instance.hasErrors()) { + renderEdit model(instance, 'update') + } else { + flashUpdated() + redirectToEdit() + } + } + + protected doUpdateWithInvalidToken(String flashArg = '') { + if (!flashArg) { + flashArg = "${message(code: 'spring.security.ui.invalid.form.default.arg')}" + } + response.status = 500 + log.warn('User: {} possible CSRF or double submit: {}', SpringSecurityUtils.authentication.principal.id as String, params as String) + flash.message = "${message(code: 'spring.security.ui.invalid.update.form', args: [flashArg])}" + redirectToSearch() + } + + protected void renderEdit(Map model) { + render view: 'edit', model: model + } + + protected void tryDelete(Closure delete) { + def instance = lookupFromParams() + if (!instance) return + + try { + delete instance + flashDeleted() + redirectToSearch() + } + catch (DataIntegrityViolationException e) { + flashNotDeleted() + redirectToEdit() + } + } + + protected doDeleteWithInvalidToken(String flashArg = '') { + if (!flashArg) { + flashArg = "${message(code: 'spring.security.ui.invalid.form.default.arg')}" + } + response.status = 500 + log.warn('User: {} possible CSRF or double submit: {}', SpringSecurityUtils.authentication.principal.id as String, params as String) + flash.message = "${message(code: 'spring.security.ui.invalid.delete.form', args: [flashArg])}" + redirectToSearch() + } + + protected abstract search() + + protected boolean isSearch(String... optionalParamNames) { + optionalParamNames.any { param it } || request.post || + params.max || params.offset || params.sort || params.order + } + + protected doSearch(Closure projection = null, Closure criteria) { + log.trace 'Search params: {}', params.toString() + + def criterias = [criteria] + if (projection) criterias << projection + + def (int max, int offset) = maxAndOffset() + String sort = params.sort + String direction + String propertyName = toPropertyName(sort) + String sortBy = '' + if (sort) { + direction = params.order ?: 'asc' + sortBy = ' order by "' + propertyName + '" ' + direction + criterias << { + + if (propertyName.indexOf('.') > -1 && !propertyName.endsWith('.id')) { + String first = propertyName.split('\\.')[0] + createAlias first, '_' + first + propertyName = '_' + propertyName + } + + order propertyName, direction + } + } + + log.trace 'Search: firstResult {} maxResults {} {}', offset as String, max as String, sortBy as String + + uiQueryStrategy.runCriteria(clazz, criterias, [max: max, offset: offset]) + } + + def ajaxSearch() { + + def jsonData + + String term = params.term + if (term?.length() >= autoCompleteMinLength) { + String paramName = params.paramName + String propertyName = toPropertyName(paramName) + params.sort = paramName + params.order = 'asc' + def results = doSearch { + like 'term', paramName, delegate + projections { + distinct propertyName + } + } + + jsonData = results.collect { result -> [value: result] } + } + + renderJson(jsonData ?: []) + } + + protected void renderJson(jsonData) { + render text: jsonData as JSON, contentType: 'text/plain' + } + + protected Closure buildProjection(String path, String criterionMethod, List args) { + uiQueryStrategy.buildProjection path, criterionMethod, args + } + + protected void eqBoolean(String paramName, delegate) { + // this is called for the radioGroup properties where 1 indicates true, + // -1 indicates false, and 0 indicates either, so only include the + // criterion if it's not null and not 0 + Boolean value + Integer i = params.int(paramName) + if (i) { + value = i == 1 + } + eq 'boolean', paramName, value, delegate + } + + protected void eqInt(String paramName, delegate) { + eq 'int', paramName, params.int(paramName), delegate + } + + protected void eqLong(String paramName, delegate) { + eq 'long', paramName, params.long(paramName), delegate + } + + protected void eqLongId(String paramName, delegate) { + eqLong paramName + '.id', delegate + } + + protected void eq(String type, String paramName, value, delegate) { + String propertyName = toPropertyName(paramName) + traceCriterion type, paramName, propertyName, value + if (value != null) { + delegate.eq propertyName, value + } + } + + protected void like(String paramName, String propertyName = null, delegate) { + if (propertyName == null) { + propertyName = toPropertyName(paramName) + } + def value = params[paramName] + log.trace '{}', traceCriterion('ilike', paramName, propertyName, value) + if (value) { + delegate.ilike propertyName, '%' + value + '%' + } + } + + protected String traceCriterion(String type, String paramName, String propertyName, value) { + def sb = new StringBuilder() + if (type == 'ilike') { + sb << type + } else { + sb << 'eq (' << type << ')' + } + sb << ' param: "' << paramName << '"' + + if (propertyName != paramName) { + sb << ' (property: "' << propertyName << '")' + } + sb << ' value: ' + + if (type == 'ilike') { + if (value == null) { + sb << 'null' + } else { + sb << '"' << value << '"' + } + } else { + sb << value + } + + sb.toString() + } + + protected void renderSearch(Map model, String... queryParamNames) { + model.searched = true + addQueryParamsToModelForPaging model, queryParamNames + render view: 'search', model: model + } + + protected void addQueryParamsToModelForPaging(Map model, String... names) { + def allNames = names as List + + def queryParams = model.queryParams = [:] + + for (name in allNames) { + def value = params[name] + if (value != null) { + String modelKey = name + if (modelKey.endsWith('.id')) { + modelKey = modelKey[0..-4] + } + queryParams[name] = model[modelKey] = value + } + } + + model.order = params.order + model.sort = params.sort + } + + protected void redirectToEdit(id = params.id) { + redirect action: 'edit', id: id + } + + protected void redirectToSearch() { + redirect action: 'search' + } + + protected abstract Map model(instance, String action) + + protected boolean versionCheck(instance) { + + Long version = params.long('version') + if (version) { + def instanceVersion = instance.version + if (instanceVersion instanceof Number && instanceVersion > version) { + instance.errors.rejectValue('version', 'default.optimistic.locking.failure', + [message(code: classLabelCode, default: simpleClassName)] as Object[], + 'Another user has updated this ' + simpleClassName + ' while you were editing') + renderEdit model(instance, 'update') + return false + } + } + + true + } + + protected lookupFromParams() { + byId() + } + + protected byId() { + def instance = clazz.get(params.id) + if (instance) return instance + + flashNotFound() + redirectToSearch() + } + + protected List maxAndOffset() { + int max = setIfMissing('max', defaultPageSize, maxPageSize) + int offset = setIfMissing('offset', 0) + [max, offset] + } + + protected fromParams() { + uiPropertiesStrategy.setProperties params, clazz, null + } + + protected int setIfMissing(String paramName, int valueIfMissing, Integer max = null) { + int value = (params[paramName] ?: valueIfMissing) as int + if (max) { + value = Math.min(value, max) + } + params[paramName] = value + value + } + + protected boolean param(String paramName) { + params.containsKey(paramName) && params[paramName] != 'null' + } + + protected String toPropertyName(String paramName) { + uiPropertiesStrategy.paramNameToPropertyName paramName, controllerName + } + + protected String labelMessage() { + message(code: classLabelCode, default: simpleClassName) + } + + protected void flashCreated(id) { + flash.message = message(code: 'default.created.message', args: [labelMessage(), id]) + } + + protected void flashUpdated() { + flash.message = message(code: 'default.updated.message', args: [labelMessage(), params.id]) + } + + protected void flashDeleted() { + flash.message = message(code: 'default.deleted.message', args: [labelMessage(), params.id]) + } + + protected void flashNotDeleted() { + flash.message = message(code: 'default.not.deleted.message', args: [labelMessage(), params.id]) + } + + protected void flashNotFound() { + flash.message = message(code: 'default.not.found.message', args: [labelMessage(), params.id]) + } + + protected abstract String getClassLabelCode() + + protected abstract Class getClazz() + + protected String getSimpleClassName() { clazz.simpleName } + + protected int getDefaultPageSize() { 10 } + + protected int getMaxPageSize() { 100 } + + protected int getAutoCompleteMinLength() { 3 } + + protected Class AclClass + protected Class AclSid + + void afterPropertiesSet() { + super.afterPropertiesSet() + AclClass = getDomainClassClass('grails.plugin.springsecurity.acl.AclClass') + AclSid = getDomainClassClass('grails.plugin.springsecurity.acl.AclSid') + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclClassController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclClassController.groovy new file mode 100644 index 00000000000..4793c6e2371 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclClassController.groovy @@ -0,0 +1,76 @@ +/* + * 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.ui + +/** + * @author Burt Beckwith + */ +class AclClassController extends AbstractS2UiDomainController { + + def save() { + withForm { + doSave uiAclStrategy.saveAclClass(params) + }.invalidToken { + doSaveWithInvalidToken(params.username) + } + } + + def update() { + withForm { + doUpdate { aclClass -> + uiAclStrategy.updateAclClass params, aclClass + } + }.invalidToken { + doUpdateWithInvalidToken(params.username) + } + } + + def delete() { + withForm { + tryDelete { aclClass -> + uiAclStrategy.deleteAclClass aclClass + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + if (!isSearch()) { + // show the form + return + } + + def results = doSearch { -> + like 'className', delegate + } + + renderSearch([results: results, totalCount: results.totalCount], 'className') + } + + protected Class getClazz() { AclClass } + + protected String getClassLabelCode() { 'aclClass.label' } + + protected String getSimpleClassName() { 'AclClass' } + + protected Map model(aclClass, String action) { + [aclClass: aclClass] + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclEntryController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclEntryController.groovy new file mode 100644 index 00000000000..9640dc7becc --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclEntryController.groovy @@ -0,0 +1,109 @@ +/* + * 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.ui + +/** + * @author Burt Beckwith + */ +class AclEntryController extends AbstractS2UiDomainController { + + def aclPermissionFactory + + def create() { + params.granting = true + super.create() + } + + def save() { + withForm { + doSave uiAclStrategy.saveAclEntry(params) + }.invalidToken { + doSaveWithInvalidToken(params.username) + } + } + + def update() { + withForm { + doUpdate { aclEntry -> + uiAclStrategy.updateAclEntry params, aclEntry + } + }.invalidToken { + doUpdateWithInvalidToken(params.username) + } + } + + def delete() { + withForm { + tryDelete { aclEntry -> + uiAclStrategy.deleteAclEntry aclEntry + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + if (!isSearch('aclClass.id', 'aclObjectIdentity.id', 'sid.id')) { + // show the form + return [sids: AclSid.list()] + } + + Closure projection + Long classId = params.long('aclClass.id') + if (classId) { + // special case for external search + projection = buildProjection('aclObjectIdentity.aclClass', 'eq', ['id', classId]) + } + + def results = doSearch(projection) { -> + + eqInt 'aceOrder', delegate + eqInt 'mask', delegate + + eqLongId 'aclObjectIdentity', delegate + eqLongId 'sid', delegate + + eqBoolean 'auditFailure', delegate + eqBoolean 'auditSuccess', delegate + eqBoolean 'granting', delegate + } + + renderSearch([results: results, totalCount: results.totalCount, + sids: AclSid.list(), permissionFactory: aclPermissionFactory], + 'aceOrder', 'aclClass.id', 'aclObjectIdentity.id', 'auditFailure', + 'auditSuccess', 'granting', 'mask', 'sid.id') + } + + protected Class getClazz() { AclEntry } + + protected String getClassLabelCode() { 'aclEntry.label' } + + protected String getSimpleClassName() { 'AclEntry' } + + protected Map model(aclEntry, String action) { + [aclEntry: aclEntry, sids: AclSid.list()] + } + + protected Class AclEntry + + void afterPropertiesSet() { + super.afterPropertiesSet() + AclEntry = getDomainClassClass('grails.plugin.springsecurity.acl.AclEntry') + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclObjectIdentityController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclObjectIdentityController.groovy new file mode 100644 index 00000000000..fa9f51d6e00 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclObjectIdentityController.groovy @@ -0,0 +1,89 @@ +/* + * 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.ui + +/** + * @author Burt Beckwith + */ +class AclObjectIdentityController extends AbstractS2UiDomainController { + + def save() { + withForm { + doSave uiAclStrategy.saveAclObjectIdentity(params) + }.invalidToken { + doSaveWithInvalidToken(params.username) + } + } + + def update() { + withForm { + doUpdate { aclObjectIdentity -> + uiAclStrategy.updateAclObjectIdentity params, aclObjectIdentity + } + }.invalidToken { + doUpdateWithInvalidToken(params.username) + } + } + + def delete() { + withForm { + tryDelete { aclObjectIdentity -> + uiAclStrategy.deleteAclObjectIdentity aclObjectIdentity + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + if (!isSearch('aclClass.id', 'owner.id')) { + // show the form + return [classes: AclClass.list(), sids: AclSid.list()] + } + + def results = doSearch { -> + eqLongId 'aclClass', delegate + eqLong 'objectId', delegate + eqLongId 'owner', delegate + eqLongId 'parent', delegate + eqBoolean 'entriesInheriting', delegate + } + + renderSearch([results: results, totalCount: results.totalCount, + classes: AclClass.list(), sids: AclSid.list()], + 'aclClass.id', 'entriesInheriting', 'objectId', 'owner.id', 'parent.id') + } + + protected Class getClazz() { AclObjectIdentity } + + protected String getClassLabelCode() { 'aclObjectIdentity.label' } + + protected String getSimpleClassName() { 'AclObjectIdentity' } + + protected Map model(aclObjectIdentity, String action) { + [aclObjectIdentity: aclObjectIdentity, classes: AclClass.list(), sids: AclSid.list()] + } + + protected Class AclObjectIdentity + + void afterPropertiesSet() { + super.afterPropertiesSet() + AclObjectIdentity = getDomainClassClass('grails.plugin.springsecurity.acl.AclObjectIdentity') + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclSidController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclSidController.groovy new file mode 100644 index 00000000000..e734e720ea7 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/AclSidController.groovy @@ -0,0 +1,78 @@ +/* + * 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.ui + +/** + * @author Burt Beckwith + */ +class AclSidController extends AbstractS2UiDomainController { + + def save() { + withForm { + doSave uiAclStrategy.saveAclSid(params) + }.invalidToken { + doSaveWithInvalidToken(params.username) + } + } + + def update() { + withForm { + doUpdate { aclSid -> + uiAclStrategy.updateAclSid params, aclSid + } + }.invalidToken { + doUpdateWithInvalidToken(params.username) + } + } + + def delete() { + withForm { + tryDelete { aclSid -> + uiAclStrategy.deleteAclSid aclSid + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + if (!isSearch()) { + // show the form + return + } + + def results = doSearch { -> + eqBoolean 'principal', delegate + like 'sid', delegate + } + + renderSearch([results: results, totalCount: results.totalCount], + 'principal', 'sid') + } + + protected Class getClazz() { AclSid } + + protected String getClassLabelCode() { 'aclSid.label' } + + protected String getSimpleClassName() { 'AclSid' } + + protected Map model(aclSid, String action) { + [aclSid: aclSid] + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/PersistentLoginController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/PersistentLoginController.groovy new file mode 100644 index 00000000000..44e153242eb --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/PersistentLoginController.groovy @@ -0,0 +1,83 @@ +/* + * 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.ui + +import grails.plugin.springsecurity.ui.strategy.PersistentLoginStrategy + +/** + * @author Burt Beckwith + */ +class PersistentLoginController extends AbstractS2UiDomainController { + + /** Dependency injection for the 'uiPersistentLoginStrategy' bean. */ + PersistentLoginStrategy uiPersistentLoginStrategy + + def update() { + withForm { + doUpdate { persistentLogin -> + uiPersistentLoginStrategy.updatePersistentLogin params, persistentLogin + } + }.invalidToken { + doUpdateWithInvalidToken(params.username) + } + } + + def delete() { + withForm { + tryDelete { persistentLogin -> + uiPersistentLoginStrategy.deletePersistentLogin persistentLogin + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + if (!isSearch()) { + // show the form + return + } + + def results = doSearch { -> + like 'series', delegate + like 'token', delegate + like 'username', delegate + } + + renderSearch([results: results, totalCount: results.totalCount], + 'series', 'token', 'username') + } + + protected Class getClazz() { PersistentLogin } + + protected String getClassLabelCode() { 'persistentLogin.label' } + + protected Map model(persistentLogin, String action) { + [persistentLogin: persistentLogin] + } + + protected Class PersistentLogin + + void afterPropertiesSet() { + super.afterPropertiesSet() + if (conf.rememberMe.persistentToken.domainClassName) { + PersistentLogin = getDomainClassClass(conf.rememberMe.persistentToken.domainClassName) + } + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RegisterController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RegisterController.groovy new file mode 100644 index 00000000000..32cf85a67ba --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RegisterController.groovy @@ -0,0 +1,376 @@ +/* + * 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.ui + +import groovy.text.SimpleTemplateEngine + +import org.springframework.context.MessageSource +import org.springframework.context.i18n.LocaleContextHolder + +import grails.config.Config +import grails.core.support.GrailsConfigurationAware +import grails.gsp.PageRenderer +import grails.plugin.springsecurity.ui.strategy.MailStrategy +import grails.plugin.springsecurity.ui.strategy.PropertiesStrategy +import grails.plugin.springsecurity.ui.strategy.RegistrationCodeStrategy + +/** + * @author Burt Beckwith + */ +class RegisterController extends AbstractS2UiController implements GrailsConfigurationAware { + + static defaultAction = 'register' + + /** Dependency injection for the 'uiMailStrategy' bean. */ + MailStrategy uiMailStrategy + + /** Dependency injection for the 'uiRegistrationCodeStrategy' bean. */ + RegistrationCodeStrategy uiRegistrationCodeStrategy + + /** Dependency injection for the 'uiPropertiesStrategy' bean. */ + PropertiesStrategy uiPropertiesStrategy + + String serverURL + + PageRenderer groovyPageRenderer + MessageSource messageSource + + static final String EMAIL_LAYOUT = '/layouts/email' + static final String FORGOT_PASSWORD_TEMPLATE = '/register/_forgotPasswordMail' + static final String VERIFY_REGISTRATION_TEMPLATE = '/register/_verifyRegistrationMail' + + @Override + void setConfiguration(Config co) { + serverURL = co.getProperty('grails.serverURL', String) + } + + def register(RegisterCommand registerCommand) { + + if (!request.post) { + return [registerCommand: new RegisterCommand()] + } + + if (registerCommand.hasErrors()) { + return [registerCommand: registerCommand] + } + + def user = uiRegistrationCodeStrategy.createUser(registerCommand) + + RegistrationCode registrationCode = uiRegistrationCodeStrategy.register(user, registerCommand.password) + + if (registrationCode == null || registrationCode.hasErrors()) { + // null means problem creating the user + flash.error = message(code: 'spring.security.ui.register.miscError') + return [registerCommand: registerCommand] + } + + if (requireEmailValidation) { + sendVerifyRegistrationMail registrationCode, user, registerCommand.email + [emailSent: true, registerCommand: registerCommand] + } else { + redirectVerifyRegistration(uiRegistrationCodeStrategy.verifyRegistration(registrationCode.token)) + } + } + + protected void redirectVerifyRegistration(def rtn) { + if (rtn?.flashmsg) { + if (rtn?.flashType == 'error') { + flash.error = message(code: rtn?.flashmsg) + } else { + flash.message = message(code: rtn?.flashmsg) + } + } + + //this should always be set but if not have a backup! + redirect uri: rtn?.redirectmsg ?: successHandlerDefaultTargetUrl + } + + protected void sendVerifyRegistrationMail(RegistrationCode registrationCode, user, String email) { + String url = generateLink('verifyRegistration', [t: registrationCode.token]) + String body = renderRegistrationMailBody(url, user) + + uiMailStrategy.sendVerifyRegistrationMail( + to: email, + from: registerEmailFrom, + subject: registerEmailSubject, + html: body + ) + } + + /** + * Render the email body using text from DefaultUiSecurityConfig if it exists. If not, render using gsps + * @param url + * @param user + * @return html mail goodness + */ + protected String renderRegistrationMailBody(String url, user) { + if (registerEmailBody) { + def body = registerEmailBody + if (body.contains('$')) { + body = evaluate(body, [user: user, url: url]) + } + return body.toString() + } + + renderEmail(VERIFY_REGISTRATION_TEMPLATE, EMAIL_LAYOUT, [ + url: url, + username: user.username + ]) + } + + def verifyRegistration() { + redirectVerifyRegistration(uiRegistrationCodeStrategy.verifyRegistration(params.t)) + } + + def forgotPassword(ForgotPasswordCommand forgotPasswordCommand) { + + if (!request.post) { + ForgotPasswordCommand fpc = new ForgotPasswordCommand() + return [forgotPasswordCommand: fpc] + } + withForm { + if (forgotPasswordCommand.hasErrors()) { + + return [forgotPasswordCommand: forgotPasswordCommand] + } + + def user = findUserByUsername(forgotPasswordCommand.username) + if (!user) { + forgotPasswordCommand.errors.rejectValue 'username', 'spring.security.ui.forgotPassword.user.notFound' + + return [forgotPasswordCommand: forgotPasswordCommand] + } + + if (forgotPasswordExtraValidation && forgotPasswordExtraValidation.size() > 0 && forgotPasswordExtraValidationDomainClassName) { + redirect uri: generateLink('securityQuestions', [username: forgotPasswordCommand.username]) + } else { + if (requireForgotPassEmailValidation) { + processForgotPasswordEmail(forgotPasswordCommand, user) + } else { + redirect uri: processForgotPasswordEmail(forgotPasswordCommand, user) + } + } + }.invalidToken { + flash.message = 'Invalid Form Submission' + redirect(controller: 'login', action: 'auth') + } + } + + def securityQuestions() { + if (forgotPasswordExtraValidation && forgotPasswordExtraValidation.size() > 0) { + def user = findUserByUsername(params.username) + SecurityQuestionsCommand sc = new SecurityQuestionsCommand() + try { + sc.username = params.username + if (!request.post) { + render(view: 'securityQuestions', model: [securityQuestionsCommand: sc, user: user, forgotPasswordExtraValidation: forgotPasswordExtraValidation, forgotPasswordExtraValidationDomainClassName: forgotPasswordExtraValidationDomainClassName, validationUserLookUpProperty: validationUserLookUpProperty]) + } else { + withForm { + def rtn = uiRegistrationCodeStrategy.validateForgotPasswordExtraSecurity(params, user, forgotPasswordExtraValidationDomainClassName, forgotPasswordExtraValidation, validationUserLookUpProperty) + if (!rtn[0]) { + sc.validations = rtn[1] + render(view: 'securityQuestions', model: [securityQuestionsCommand: sc, user: user, forgotPasswordExtraValidation: forgotPasswordExtraValidation, forgotPasswordExtraValidationDomainClassName: forgotPasswordExtraValidationDomainClassName, validationUserLookUpProperty: validationUserLookUpProperty]) + } else { + ForgotPasswordCommand fc = new ForgotPasswordCommand() + fc.username = sc.username + if (requireForgotPassEmailValidation) { + render(view: 'forgotPassword', processForgotPasswordEmail(fc, user)) + } else { + redirect uri: processForgotPasswordEmail(fc, user) + } + } + }.invalidToken { + flash.message = 'Invalid Form Submission' + redirect(controller: 'login', action: 'auth') + } + } + } catch (Exception e) { + flash.error = e.getMessage() + render(view: 'securityQuestions', model: [securityQuestionsCommand: sc, user: user, forgotPasswordExtraValidation: forgotPasswordExtraValidation, forgotPasswordExtraValidationDomainClassName: forgotPasswordExtraValidationDomainClassName, validationUserLookUpProperty: validationUserLookUpProperty]) + } + } else { + redirect uri: generateLink('register') + } + } + + protected def processForgotPasswordEmail(forgotPasswordCommand, user) { + + if (requireForgotPassEmailValidation) { + String email = uiPropertiesStrategy.getProperty(user, 'email') + if (!email) { + forgotPasswordCommand.errors.rejectValue 'username', 'spring.security.ui.forgotPassword.noEmail' + return [forgotPasswordCommand: forgotPasswordCommand] + } + uiRegistrationCodeStrategy.sendForgotPasswordMail( + forgotPasswordCommand.username, email) { String registrationCodeToken -> + + String url = generateLink('resetPassword', [t: registrationCodeToken]) + String body = forgotPasswordEmailBody + + if (!body) { + body = renderEmail( + FORGOT_PASSWORD_TEMPLATE, EMAIL_LAYOUT, + [ + url: url, + username: user.username + ] + ) + } else if (body.contains('$')) { + body = evaluate(body, [user: user, url: url]) + } + + body + } + [emailSent: true, forgotPasswordCommand: forgotPasswordCommand] + } else { + return generateLink('resetPassword', [t: uiRegistrationCodeStrategy.sendForgotPasswordMail(forgotPasswordCommand.username)?.token]) + } + } + + private String renderEmail(String viewPath, String layoutPath, Map model) { + String content = groovyPageRenderer.render(view: viewPath, model: model) + return groovyPageRenderer.render(view: layoutPath, model: model << [content: content]) + } + + def resetPassword(ResetPasswordCommand resetPasswordCommand) { + + String token = params.t + + def registrationCode = token ? RegistrationCode.findByToken(token) : null + if (!registrationCode) { + flash.error = message(code: 'spring.security.ui.resetPassword.badCode') + redirect uri: successHandlerDefaultTargetUrl + return + } + + if (!request.post) { + return [token: token, resetPasswordCommand: new ResetPasswordCommand()] + } + + resetPasswordCommand.username = registrationCode.username + resetPasswordCommand.validate() + if (resetPasswordCommand.hasErrors()) { + return [token: token, resetPasswordCommand: resetPasswordCommand] + } + + def user = uiRegistrationCodeStrategy.resetPassword(resetPasswordCommand, registrationCode) + if (user.hasErrors()) { + // expected to be handled already by ErrorsStrategy.handleValidationErrors + } + + flash.message = message(code: 'spring.security.ui.resetPassword.success') + + redirect uri: registerPostResetUrl ?: successHandlerDefaultTargetUrl + } + + /** + * Creates a grails application link from a set of attributes. + * @param action + * @param linkParams + * @param shouldUseServerUrl (optional) - If true, will utilize the configured grails.serverURL from application.yml if it exists otherwise the base url will be constructed the same as it always has been + * @return String representing the relative or absolute URL + */ + protected String generateLink(String action, Map linkParams, boolean shouldUseServerUrl = false) { + String base = "$request.scheme://$request.serverName:$request.serverPort$request.contextPath" + + if (shouldUseServerUrl && serverURL) { + base = serverURL + } + + createLink( + base: base, + controller: 'register', + action: action, + params: linkParams) + } + + protected String evaluate(s, binding) { + new SimpleTemplateEngine().createTemplate(s).make(binding) + } + + protected String forgotPasswordEmailBody + protected Boolean requireForgotPassEmailValidation + protected List forgotPasswordExtraValidation + protected String forgotPasswordExtraValidationDomainClassName + protected String registerEmailBody + protected String registerEmailFrom + protected String registerEmailSubject + protected String registerPostRegisterUrl + protected String registerPostResetUrl + protected String successHandlerDefaultTargetUrl + protected Boolean requireEmailValidation + protected static int passwordMaxLength + protected String validationUserLookUpProperty + protected static int passwordMinLength + protected static String passwordValidationRegex + + void afterPropertiesSet() { + super.afterPropertiesSet() + + RegisterCommand.User = User + RegisterCommand.usernamePropertyName = usernamePropertyName + + forgotPasswordEmailBody = conf.ui.forgotPassword.emailBody ?: '' + requireForgotPassEmailValidation = conf.ui.forgotPassword.requireForgotPassEmailValidation instanceof groovy.util.ConfigObject ? true : Boolean.valueOf(conf.ui.forgotPassword.requireForgotPassEmailValidation) + forgotPasswordExtraValidation = conf.ui.forgotPassword.forgotPasswordExtraValidation ?: [] + forgotPasswordExtraValidationDomainClassName = (conf.ui.forgotPassword.forgotPasswordExtraValidationDomainClassName ?: '').toString().trim() + registerEmailBody = conf.ui.register.emailBody ?: '' + registerEmailFrom = conf.ui.register.emailFrom ?: '' + validationUserLookUpProperty = conf.ui.forgotPassword.validationUserLookUpProperty ?: 'user' + registerEmailSubject = conf.ui.register.emailSubject ?: messageSource ? messageSource.getMessage('spring.security.ui.register.email.subject', [].toArray(), 'New Account', LocaleContextHolder.locale) : '' ?: '' + registerPostRegisterUrl = conf.ui.register.postRegisterUrl ?: '' + registerPostResetUrl = conf.ui.forgotPassword.postResetUrl ?: '' + successHandlerDefaultTargetUrl = conf.successHandler.defaultTargetUrl ?: '/' + requireEmailValidation = conf.ui.register.requireEmailValidation instanceof groovy.util.ConfigObject ? true : Boolean.valueOf(conf.ui.register.requireEmailValidation) + passwordMaxLength = conf.ui.password.maxLength instanceof Number ? conf.ui.password.maxLength : 64 + passwordMinLength = conf.ui.password.minLength instanceof Number ? conf.ui.password.minLength : 8 + passwordValidationRegex = conf.ui.password.validationRegex ?: '^.*(?=.*\\d)(?=.*[a-zA-Z])(?=.*[!@#$%^&]).*$' + } + + static final passwordValidator = { String password, command -> + if (command.username && command.username.equals(password)) { + return 'command.password.error.username' + } + + if (!checkPasswordMinLength(password, command) || !checkPasswordMaxLength(password, command)) { + return ['command.password.error.length', passwordMinLength, passwordMaxLength] + } + if (!checkPasswordRegex(password, command)) { + return 'command.password.error.strength' + } + } + + static boolean checkPasswordMinLength(String password, command) { + password && password.length() >= passwordMinLength + } + + static boolean checkPasswordMaxLength(String password, command) { + password && password.length() <= passwordMaxLength + } + + static boolean checkPasswordRegex(String password, command) { + password && password.matches(passwordValidationRegex) + } + + static final password2Validator = { value, command -> + if (command.password != command.password2) { + return 'command.password2.error.mismatch' + } + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RegistrationCodeController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RegistrationCodeController.groovy new file mode 100644 index 00000000000..f4705e372ff --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RegistrationCodeController.groovy @@ -0,0 +1,75 @@ +/* + * 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.ui + +import grails.plugin.springsecurity.ui.strategy.RegistrationCodeStrategy + +/** + * @author Burt Beckwith + */ +class RegistrationCodeController extends AbstractS2UiDomainController { + + /** Dependency injection for the 'uiRegistrationCodeStrategy' bean. */ + RegistrationCodeStrategy uiRegistrationCodeStrategy + + def update() { + withForm { + doUpdate { registrationCode -> + uiRegistrationCodeStrategy.updateRegistrationCode params, registrationCode + } + }.invalidToken { + doUpdateWithInvalidToken() + } + } + + def delete() { + withForm { + tryDelete { registrationCode -> + uiRegistrationCodeStrategy.deleteRegistrationCode registrationCode + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + if (!isSearch()) { + // show the form + return + } + + def results = doSearch { -> + like 'token', delegate + like 'username', delegate + } + + renderSearch([results: results, totalCount: results.totalCount], + 'token', 'username') + } + + protected Class getClazz() { RegistrationCode } + + protected String getClassLabelCode() { 'registrationCode.label' } + + protected String getSimpleClassName() { 'RegistrationCode' } + + protected Map model(registrationCode, String action) { + [registrationCode: registrationCode] + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RequestmapController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RequestmapController.groovy new file mode 100644 index 00000000000..092e64623e1 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RequestmapController.groovy @@ -0,0 +1,112 @@ +/* + * 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.ui + +import org.springframework.http.HttpMethod + +import grails.plugin.springsecurity.ReflectionUtils +import grails.plugin.springsecurity.ui.strategy.RequestmapStrategy + +/** + * @author Burt Beckwith + */ +class RequestmapController extends AbstractS2UiDomainController { + + /** Dependency injection for the 'uiRequestmapStrategy' bean. */ + RequestmapStrategy uiRequestmapStrategy + + def springSecurityService + + def save() { + withForm { + if (!(param('url'))) { + params.remove('url') + } + doSave(uiRequestmapStrategy.saveRequestmap(params)) { + springSecurityService.clearCachedRequestmaps() + } + }.invalidToken { + doSaveWithInvalidToken(params.url) + } + } + + def update() { + withForm { + if (!(param('url'))) { + params.remove('url') + } + doUpdate { requestmap -> + uiRequestmapStrategy.updateRequestmap params, requestmap + } + }.invalidToken { + doUpdateWithInvalidToken(params.url) + } + } + + def delete() { + withForm { + tryDelete { requestmap -> + uiRequestmapStrategy.deleteRequestmap requestmap + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + if (!isSearch()) { + // show the form + return [hasHttpMethod: hasHttpMethod] + } + + def results = doSearch { -> + like 'configAttribute', delegate + like 'url', delegate + if (param('httpMethod')) { + eq 'httpMethod', HttpMethod.valueOf(params.httpMethod), delegate + } + } + + renderSearch([results: results, totalCount: results.totalCount, hasHttpMethod: hasHttpMethod], + 'configAttribute', 'httpMethod', 'url') + } + + protected Class getClazz() { Requestmap } + + protected String getClassLabelCode() { 'requestmap.label' } + + protected Map model(requestmap, String action) { + [requestmap: requestmap, hasHttpMethod: hasHttpMethod] + } + + protected boolean hasHttpMethod + protected Class Requestmap + + void afterPropertiesSet() { + super.afterPropertiesSet() + + if (!conf.requestMap.className) { + return + } + + hasHttpMethod = ReflectionUtils.requestmapClassSupportsHttpMethod() + + Requestmap = getDomainClassClass(conf.requestMap.className) + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RoleController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RoleController.groovy new file mode 100644 index 00000000000..7ee74df9c71 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/RoleController.groovy @@ -0,0 +1,127 @@ +/* + * 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.ui + +import grails.plugin.springsecurity.ui.strategy.RoleStrategy +import grails.util.GrailsNameUtils + +/** + * @author Burt Beckwith + */ +class RoleController extends AbstractS2UiDomainController { + + /** Dependency injection for the 'uiRoleStrategy' bean. */ + RoleStrategy uiRoleStrategy + + def save() { + withForm { + doSave uiRoleStrategy.saveRole(params) + }.invalidToken { + doSaveWithInvalidToken(params.authority) + } + } + + def update() { + withForm { + doUpdate { role -> + uiRoleStrategy.updateRole params, role + } + }.invalidToken { + doUpdateWithInvalidToken(params.authority) + } + } + + def delete() { + withForm { + tryDelete { role -> + uiRoleStrategy.deleteRole role + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + + if (!isSearch()) { + // show the form + return + } + + boolean useOffset = params.containsKey('offset') + params.sort = 'authority' + if (!param('authority')) params.authority = 'ROLE_' + + def results = doSearch { + like 'authority', delegate + } + + if (results.totalCount == 1 && !useOffset) { + forward action: 'edit', params: [name: results[0][authorityNameField]] + return + } + + renderSearch([results: results, totalCount: results.totalCount], 'authority') + } + + protected lookupFromParams() { + def role = params.name ? Role.findWhere((authorityNameField): params.name) : null + role ?: byId() + } + + protected getTabData() { + [ + [name: 'roleinfo', icon: 'icon_role', message: message(code: 'spring.security.ui.role.info')], + [name: 'users', icon: 'icon_users', message: message(code: 'spring.security.ui.role.users')] + ] + } + + protected Map model(role, String action) { + + def model = [role: role, tabData: tabData] + + if (action == 'edit' || action == 'update') { + maxAndOffset() + model.users = UserRole."findAllBy$roleClassSimpleName"(role, params)*."$userField" + model.userCount = UserRole."countBy$roleClassSimpleName"(role) + } + + model + } + + protected Class getClazz() { Role } + + protected String getClassLabelCode() { 'role.label' } + + protected int getAutoCompleteMinLength() { 2 } + + protected String roleClassSimpleName + protected String userField + + void afterPropertiesSet() { + super.afterPropertiesSet() + + if (!conf.authority.className) { + return + } + + roleClassSimpleName = Role.simpleName + userField = GrailsNameUtils.getPropertyName(User.simpleName) + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/SecurityInfoController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/SecurityInfoController.groovy new file mode 100644 index 00000000000..fe0d502a934 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/SecurityInfoController.groovy @@ -0,0 +1,86 @@ +/* + * 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.ui + +import org.springframework.security.access.AccessDecisionManager +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.UserCache +import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource +import org.springframework.security.web.authentication.logout.LogoutHandler + +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.web.GrailsSecurityFilterChain +import grails.plugin.springsecurity.web.access.intercept.AbstractFilterInvocationDefinition + +/** + * @author Burt Beckwith + */ +class SecurityInfoController { + + AccessDecisionManager accessDecisionManager + AuthenticationManager authenticationManager + FilterInvocationSecurityMetadataSource channelFilterInvocationSecurityMetadataSource + List logoutHandlers + AbstractFilterInvocationDefinition objectDefinitionSource + List securityFilterChains + UserCache userCache + + def config() { + [conf: new TreeMap(conf.flatten())] + } + + def mappings() { + // List + [configAttributes: objectDefinitionSource.configAttributeMap, + securityConfigType: conf.securityConfigType] + } + + def currentAuth() { + [auth: SecurityContextHolder.context.authentication] + } + + def usercache() { + [cache: conf.cacheUsers ? userCache.cache.nativeCache : false] + } + + def filterChains() { + [securityFilterChains: securityFilterChains] + } + + def logoutHandlers() { + [handlers: logoutHandlers] + } + + def voters() { + [voters: accessDecisionManager.decisionVoters] + } + + def providers() { + [providers: authenticationManager.providers] + } + + def secureChannel() { + [requestMap: channelFilterInvocationSecurityMetadataSource?.requestMap] + } + + protected ConfigObject getConf() { + SpringSecurityUtils.securityConfig + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/UserController.groovy b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/UserController.groovy new file mode 100644 index 00000000000..9f0989ff7fe --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/controllers/grails/plugin/springsecurity/ui/UserController.groovy @@ -0,0 +1,144 @@ +/* + * 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.ui + +import groovy.transform.CompileStatic + +import grails.plugin.springsecurity.ui.strategy.UserStrategy + +/** + * @author Burt Beckwith + */ +class UserController extends AbstractS2UiDomainController { + + /** Dependency injection for the 'uiUserStrategy' bean. */ + UserStrategy uiUserStrategy + + def save() { + withForm { + doSave uiUserStrategy.saveUser(params, roleNamesFromParams(), params.password) + }.invalidToken { + doSaveWithInvalidToken(params.username) + } + } + + def update() { + withForm { + doUpdate { user -> + uiUserStrategy.updateUser params, user, roleNamesFromParams() + } + }.invalidToken { + doUpdateWithInvalidToken(params.username) + } + } + + def delete() { + withForm { + tryDelete { user -> + uiUserStrategy.deleteUser user + } + }.invalidToken { + doDeleteWithInvalidToken() + } + } + + def search() { + if (!isSearch()) { + // show the form + return + } + + def results = doSearch { -> + like 'username', delegate + eqBoolean 'accountExpired', delegate + eqBoolean 'accountLocked', delegate + eqBoolean 'enabled', delegate + eqBoolean 'passwordExpired', delegate + } + + renderSearch results: results, totalCount: results.totalCount, + 'accountExpired', 'accountLocked', 'enabled', 'passwordExpired', 'username' + } + + protected lookupFromParams() { + findUserByUsername(params.username) ?: byId() + } + + protected List roleNamesFromParams() { + params.keySet().findAll { it.contains('ROLE_') && params[it] == 'on' } as List + } + + protected Map buildUserModel(user) { + + Set userRoleNames = user[authoritiesPropertyName].collect { it[authorityNameField] } + Map roleMap = buildRoleMap(userRoleNames, sortedRoles()) + + [roleMap: roleMap, tabData: tabData, user: user] + } + + @CompileStatic + protected Map buildRoleMap(Set userRoleNames, List sortedRoles) { + Map granted = [:] + Map notGranted = [:] + for (role in sortedRoles) { + String authority = role[authorityNameField] + if (userRoleNames?.contains(authority)) { + granted[(role)] = true + } else { + notGranted[(role)] = false + } + } + granted + notGranted + } + + protected List sortedRoles() { + Role.list().sort { it[authorityNameField] } + } + + protected getTabData() { + [ + [name: 'userinfo', icon: 'icon_user', message: message(code: 'spring.security.ui.user.info')], + [name: 'roles', icon: 'icon_role', message: message(code: 'spring.security.ui.user.roles')] + ] + } + + protected Class getClazz() { User } + + protected String getClassLabelCode() { 'user.label' } + + protected Map model(user, String action) { + if (action == 'edit' || action == 'update') { + buildUserModel user + } else { + [user: user, authorityList: sortedRoles(), tabData: tabData] + } + } + + protected String authoritiesPropertyName + + void afterPropertiesSet() { + super.afterPropertiesSet() + + if (!conf.userLookup.userDomainClassName) { + return + } + + authoritiesPropertyName = conf.userLookup.authoritiesPropertyName ?: '' + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/domain/grails/plugin/springsecurity/ui/RegistrationCode.groovy b/grails-spring-security/ui/plugin/grails-app/domain/grails/plugin/springsecurity/ui/RegistrationCode.groovy new file mode 100644 index 00000000000..1b9d742bf48 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/domain/grails/plugin/springsecurity/ui/RegistrationCode.groovy @@ -0,0 +1,38 @@ +/* + * 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.ui + +/** + * @author Burt Beckwith + */ +class RegistrationCode { + + String username + String token = UUID.randomUUID().toString().replaceAll('-', '') + Date dateCreated + + static constraints = { + username nullable: false + token nullable: false + } + + static mapping = { + version false + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/i18n/messages.spring-security-ui.properties b/grails-spring-security/ui/plugin/grails-app/i18n/messages.spring-security-ui.properties new file mode 100644 index 00000000000..e9028dbf8ed --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/i18n/messages.spring-security-ui.properties @@ -0,0 +1,199 @@ +# +# 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. +# +spring.security.ui.button.cancel.label Cancel +spring.security.ui.button.delete.label Delete +spring.security.ui.create Create +spring.security.ui.dateFormatGsp yyyy-MM-dd +spring.security.ui.dateFormatJs yy-mm-dd +spring.security.ui.defaultTitle Spring Security Management Console + +spring.security.ui.login.cancel Cancel +spring.security.ui.login.forgotPassword Forgot password? +spring.security.ui.login.loggedInAs Logged in as {0} +spring.security.ui.login.loggingYouIn Logging you in ... +spring.security.ui.login.login Log in +spring.security.ui.login.logout Logout +spring.security.ui.login.password Password: +spring.security.ui.login.register Register as new User +spring.security.ui.login.rememberme Remember me +spring.security.ui.login.resumeAs Resume as {0} +spring.security.ui.login.signin Member sign in +spring.security.ui.login.title Login +spring.security.ui.login.username Username: + +spring.security.ui.menu.acl ACL +spring.security.ui.menu.aclClass Class +spring.security.ui.menu.aclEntry Entry +spring.security.ui.menu.aclObjectIdentity OID +spring.security.ui.menu.aclSid SID +spring.security.ui.menu.persistentLogin Persistent Logins +spring.security.ui.menu.registrationCode Registration Code +spring.security.ui.menu.requestmap Requestmaps +spring.security.ui.menu.role Roles +spring.security.ui.menu.securityInfo Security Info +spring.security.ui.menu.securityInfo.config Security Configuration +spring.security.ui.menu.securityInfo.currentAuth Current Authentication +spring.security.ui.menu.securityInfo.filterChains Filter Chains +spring.security.ui.menu.securityInfo.logoutHandlers Logout Handlers +spring.security.ui.menu.securityInfo.mappings Mappings +spring.security.ui.menu.securityInfo.providers Authentication Providers +spring.security.ui.menu.securityInfo.secureChannel Secure Channel Definition +spring.security.ui.menu.securityInfo.usercache User Cache +spring.security.ui.menu.securityInfo.usercache.cachedUsers Cached User(s) +spring.security.ui.menu.securityInfo.usercache.statistics Statistics +spring.security.ui.menu.securityInfo.voters Voters +spring.security.ui.menu.user Users + +spring.security.ui.aclClass.edit.viewEntries View Associated ACL Entries +spring.security.ui.aclClass.edit.viewOids View Associated OIDs +spring.security.ui.aclClass.search AclClass Search +spring.security.ui.aclEntry.search AclEntry Search +spring.security.ui.aclObjectIdentity.search AclObjectIdentity Search +spring.security.ui.aclSid.search AclSid Search +spring.security.ui.persistentLogin.search PersistentLogin Search +spring.security.ui.registrationCode.search Registration Code Search +spring.security.ui.requestmap.search Requestmap Search +spring.security.ui.role.info Role Details +spring.security.ui.role.search Role Search +spring.security.ui.role.users Users +spring.security.ui.role_no_users No users have this role +spring.security.ui.runas.submit Login as user +spring.security.ui.search Search +spring.security.ui.list List +spring.security.ui.search.either Either +spring.security.ui.search.false False +spring.security.ui.search.noResults No results +spring.security.ui.search.summary Showing {0} through {1} out of {2}. +spring.security.ui.search.true True +spring.security.ui.user.info User Details +spring.security.ui.user.roles Roles +spring.security.ui.user.search User Search + +spring.security.ui.info.config.header.name Name +spring.security.ui.info.config.header.value Value +spring.security.ui.info.currentAuth.header.name Name +spring.security.ui.info.currentAuth.header.value Value +spring.security.ui.info.currentAuth.label.authorities Authorities +spring.security.ui.info.currentAuth.label.details Details +spring.security.ui.info.currentAuth.label.name Name +spring.security.ui.info.currentAuth.label.principal Principal +spring.security.ui.info.filterChains.header.filters Filters +spring.security.ui.info.filterChains.header.pattern URL Pattern +spring.security.ui.info.filterChains.none none +spring.security.ui.info.logoutHandlers.header.className Class Name +spring.security.ui.info.mappings.header.attribute ConfigAttributes +spring.security.ui.info.mappings.header.method HTTP Method +spring.security.ui.info.mappings.header.pattern Pattern +spring.security.ui.info.mappings.type SecurityConfigType: {0} +spring.security.ui.info.providers.header.className Class Name +spring.security.ui.info.secureChannel.disabled Not using Channel Security +spring.security.ui.info.secureChannel.header.attributes ConfigAttributes +spring.security.ui.info.secureChannel.header.pattern Pattern +spring.security.ui.info.usercache.cachedUsers.header.user User +spring.security.ui.info.usercache.cachedUsers.header.username Username +spring.security.ui.info.usercache.classname UserCache class: {0} +spring.security.ui.info.usercache.disabled Not Caching Users +spring.security.ui.info.usercache.header.attribute Attribute +spring.security.ui.info.usercache.header.value Value +spring.security.ui.info.usercache.label.name Name +spring.security.ui.info.usercache.label.stats.cacheHits Cache Hits +spring.security.ui.info.usercache.label.stats.cacheHitPercentage Cache Hit Percentage +spring.security.ui.info.usercache.label.stats.cacheMisses Cache Misses +spring.security.ui.info.usercache.label.stats.cacheMissPercentage Cache Miss Percentage +spring.security.ui.info.usercache.label.stats.cacheGets Cache Gets +spring.security.ui.info.usercache.label.stats.cachePuts Cache Puts +spring.security.ui.info.usercache.label.stats.cacheRemovals Cache Removals +spring.security.ui.info.usercache.label.stats.evictionCount Eviction Count +spring.security.ui.info.usercache.statistics.header.attribute Attribute +spring.security.ui.info.usercache.statistics.header.value Value +spring.security.ui.info.voters.header.className Class Name + +spring.security.ui.forgotPassword.description Enter your username and we'll send a link to reset your password to the address we have for your account. +spring.security.ui.forgotPassword.header Forgot Password +spring.security.ui.forgotPassword.noEmail We have no email registered for your account +spring.security.ui.forgotPassword.sent Your password reset email was sent - check your mail! +spring.security.ui.forgotPassword.submit Reset my password +spring.security.ui.forgotPassword.title Forgot Password +spring.security.ui.forgotPassword.user.notFound No user was found with that username +spring.security.ui.forgotPassword.username Username +spring.security.ui.forgotPassword.email.subject Password Reset +spring.security.ui.forgotPassword.email.greeting Hi {0} +spring.security.ui.forgotPassword.email.here here +spring.security.ui.forgotPassword.email.line1 You (or someone pretending to be you) requested that your password be reset. +spring.security.ui.forgotPassword.email.line2 If you didn't make this request then ignore the email; no changes have been made. +spring.security.ui.forgotPassword.email.line3 If you did make the request, then click +spring.security.ui.forgotPassword.email.line4 to reset your password. +spring.security.ui.forgotPassword.email.line4 to reset your password. + + +spring.security.ui.securityQuestions.title Security Questions +spring.security.ui.securityQuestions.description Answer the questions and we'll send a link to reset your password to the address we have for your account. +spring.security.ui.securityQuestions.extraValidationString.notequal The value entered is not correct. +spring.security.ui.securityQuestions.extraValidationString.config The property or object are not not found +spring.security.ui.securityQuestions.submit Validate My Questions +spring.security.ui.securityQuestions.header Security Questions +spring.security.ui.securityQuestion.MissingQuestions Your Questions Are Not Setup; Please contact an Administrator for help. + + +spring.security.ui.register.badCode Sorry, we have no record of that request, or it has expired +spring.security.ui.register.complete Your registration is complete +spring.security.ui.register.defaultTitle User Registration +spring.security.ui.register.header Create Account +spring.security.ui.register.miscError Sorry, there was a problem processing your registration +spring.security.ui.register.sent Your account registration email was sent - check your mail! +spring.security.ui.register.submit Create your account +spring.security.ui.register.title Register +spring.security.ui.register.email.subject New Account +spring.security.ui.register.email.greeting Hi {0} +spring.security.ui.register.email.here here +spring.security.ui.register.email.line1 You (or someone pretending to be you) created an account with this email address. +spring.security.ui.register.email.line2 If you made the request, please click +spring.security.ui.register.email.line3 to finish the registration. + +spring.security.ui.resetPassword.badCode Sorry, we have no record of that request, or it has expired +spring.security.ui.resetPassword.description Enter your new password +spring.security.ui.resetPassword.header Reset Password +spring.security.ui.resetPassword.submit Update my password +spring.security.ui.resetPassword.success Your password was successfully changed +spring.security.ui.resetPassword.title Reset Password + +command.password.error.strength Password must have at least one letter, number, and special character: \!@\#$%^& +command.password.error.length Password must have min {3} and maximum {4} characters +command.password.error.username Password cannot be the same as the username +command.password2.error.mismatch Passwords do not match + +registerCommand.email.email.invalid Please provide a valid email address +registerCommand.email.nullable Email is required +registerCommand.password.maxSize.exceeded Password must be between 8 and 64 characters +registerCommand.password.minSize.notmet Password must be between 8 and 64 characters +registerCommand.password.nullable Password is required +registerCommand.password2.nullable Password is required +registerCommand.username.nullable Username is required +registerCommand.username.unique The username is taken + +resetPasswordCommand.password.nullable Password is required +resetPasswordCommand.password.maxSize.exceeded Password must be between 8 and 64 characters +resetPasswordCommand.password.minSize.notmet Password must be between 8 and 64 characters + +forgotPasswordCommand.username.nullable Please enter your username + +spring.security.ui.invalid.form.default.arg Oops! That +spring.security.ui.invalid.save.form {0} may not have been created. Either the form was submitted twice or possible CSRF attempt. Be careful what you click. +spring.security.ui.invalid.update.form {0} may not have been updated. Either the form was submitted twice or possible CSRF attempt. Be careful what you click. +spring.security.ui.invalid.delete.form {0} may not have been deleted. Either the form was submitted twice or possible CSRF attempt. Be careful what you click. diff --git a/grails-spring-security/ui/plugin/grails-app/services/grails/plugin/springsecurity/ui/SpringSecurityUiService.groovy b/grails-spring-security/ui/plugin/grails-app/services/grails/plugin/springsecurity/ui/SpringSecurityUiService.groovy new file mode 100644 index 00000000000..c2fe5704d16 --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/services/grails/plugin/springsecurity/ui/SpringSecurityUiService.groovy @@ -0,0 +1,833 @@ +/* + * 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.ui + +import java.text.SimpleDateFormat + +import groovy.util.logging.Slf4j + +import org.springframework.context.MessageSource +import org.springframework.context.i18n.LocaleContextHolder +import org.springframework.security.core.userdetails.UserCache +import org.springframework.transaction.TransactionStatus +import org.springframework.util.ClassUtils + +import grails.core.GrailsApplication +import grails.gorm.transactions.Transactional +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.ui.strategy.AclStrategy +import grails.plugin.springsecurity.ui.strategy.ErrorsStrategy +import grails.plugin.springsecurity.ui.strategy.MailStrategy +import grails.plugin.springsecurity.ui.strategy.PersistentLoginStrategy +import grails.plugin.springsecurity.ui.strategy.PropertiesStrategy +import grails.plugin.springsecurity.ui.strategy.QueryStrategy +import grails.plugin.springsecurity.ui.strategy.RegistrationCodeStrategy +import grails.plugin.springsecurity.ui.strategy.RequestmapStrategy +import grails.plugin.springsecurity.ui.strategy.RoleStrategy +import grails.plugin.springsecurity.ui.strategy.UserStrategy +import grails.util.GrailsNameUtils + +/** + * Helper methods for UI management. + * + * @author Burt Beckwith + */ +@Slf4j +class SpringSecurityUiService implements AclStrategy, ErrorsStrategy, PersistentLoginStrategy, + PropertiesStrategy, QueryStrategy, RegistrationCodeStrategy, + RequestmapStrategy, RoleStrategy, UserStrategy { + + public static final String DATE_FORMAT = 'd MMM yyyy HH:mm:ss' + + protected static final String ACL_CLASS_NAME = 'grails.plugin.springsecurity.acl.AclClass' + protected static final String ACL_ENTRY_NAME = 'grails.plugin.springsecurity.acl.AclEntry' + protected static final String ACL_OBJECT_IDENTITY_NAME = 'grails.plugin.springsecurity.acl.AclObjectIdentity' + protected static final String ACL_SID_NAME = 'grails.plugin.springsecurity.acl.AclSid' + + protected Map, Map> classMappings + + /** Dependency injection for the 'uiErrorsStrategy' bean. */ + ErrorsStrategy uiErrorsStrategy + + /** Dependency injection for the 'uiMailStrategy' bean. */ + MailStrategy uiMailStrategy + + /** Dependency injection for the 'uiPropertiesStrategy' bean. */ + PropertiesStrategy uiPropertiesStrategy + + GrailsApplication grailsApplication + MessageSource messageSource + def springSecurityService + UserCache userCache + + @Transactional + def saveAclClass(Map properties) { + save properties, AclClass, 'saveAclClass', transactionStatus + } + + @Transactional + void updateAclClass(Map properties, aclClass) { + save properties, aclClass, 'updateAclClass', transactionStatus + } + + @Transactional + void deleteAclClass(aclClass) { + // will fail if there are FK references + delete aclClass, 'deleteAclClass', transactionStatus + } + + @Transactional + def saveAclEntry(Map properties) { + save(properties, AclEntry, 'saveAclEntry', transactionStatus) { aclEntry -> + removeIfTransient aclEntry, 'aclObjectIdentity', 'sid' + } + } + + @Transactional + void updateAclEntry(Map properties, aclEntry) { + save(properties, aclEntry, 'updateAclEntry', transactionStatus) { + removeIfTransient aclEntry, 'aclObjectIdentity', 'sid' + } + } + + @Transactional + void deleteAclEntry(aclEntry) { + delete aclEntry, 'deleteAclEntry', transactionStatus + } + + @Transactional + def saveAclObjectIdentity(Map properties) { + save(properties, AclObjectIdentity, 'saveAclObjectIdentity', transactionStatus) { aclObjectIdentity -> + removeIfTransient aclObjectIdentity, 'aclClass', 'owner', 'parent' + } + } + + @Transactional + void updateAclObjectIdentity(Map properties, aclObjectIdentity) { + save properties, aclObjectIdentity, 'updateAclObjectIdentity', transactionStatus + } + + @Transactional + void deleteAclObjectIdentity(aclObjectIdentity) { + // will fail if there are FK references + delete aclObjectIdentity, 'deleteAclObjectIdentity', transactionStatus + } + + @Transactional + def saveAclSid(Map properties) { + save properties, AclSid, 'saveAclSid', transactionStatus + } + + @Transactional + void updateAclSid(Map properties, aclSid) { + save properties, aclSid, 'updateAclSid', transactionStatus + } + + @Transactional + void deleteAclSid(aclSid) { + // will fail if there are FK references + delete aclSid, 'deleteAclSid', transactionStatus + } + + @Transactional + void updatePersistentLogin(Map properties, persistentLogin) { + properties = [:] + properties + if (properties.lastUsed && properties.lastUsed instanceof String) { + Calendar c = Calendar.instance + c.time = new SimpleDateFormat(DATE_FORMAT).parse(properties.lastUsed) + properties.lastUsed = c.time + } + + save properties, persistentLogin, 'updatePersistentLogin', transactionStatus + } + + @Transactional + void deletePersistentLogin(persistentLogin) { + delete persistentLogin, 'deletePersistentLogin', transactionStatus + } + + @Transactional + void updateRegistrationCode(Map properties, RegistrationCode registrationCode) { + save properties, registrationCode, 'updateRegistrationCode', transactionStatus + } + + @Transactional + void deleteRegistrationCode(RegistrationCode registrationCode) { + delete registrationCode, 'deleteRegistrationCode', transactionStatus + } + + @Transactional + RegistrationCode register(user, String password) { + + save password: encodePassword(password), user, 'register', transactionStatus + if (user.hasErrors()) { + return + } + + String username = uiPropertiesStrategy.getProperty(user, 'username') + save username: username, RegistrationCode, 'register', transactionStatus + } + + def createUser(RegisterCommand command) { + uiPropertiesStrategy.setProperties( + email: command.email, username: command.username, accountLocked: true, enabled: true, User, null) + } + + /*Abstracted so this method can be used in other applications and if we determine to use another matching stragery later we can update as needed + */ + + boolean doesAnswerMatch(String val1, String val2) { + springSecurityService.passwordEncoder.matches(val2, val1) + } + + @Transactional + def validateForgotPasswordExtraSecurity(params, user, forgotPasswordExtraValidationDomainClassName, forgotPasswordExtraValidation, String validationUserLookUpProperty) { + Boolean isvalid = true + String instance + def domain = grailsApplication.getClassForName(forgotPasswordExtraValidationDomainClassName) + if (!(domain instanceof Object)) { + return [false, [errorMsg: messageSource.getMessage('spring.security.ui.securityQuestions.extraValidationString.config', null, 'Domain Not Found', LocaleContextHolder.getLocale())]] + } + List rtnValidations = [] + forgotPasswordExtraValidation?.eachWithIndex { it, idx -> + HashMap inst = forgotPasswordExtraValidation.getAt(idx) + rtnValidations[idx] = [:] + instance = this.getProperty(domain.findWhere((validationUserLookUpProperty): user), inst.prop) + rtnValidations[idx].valueTxt = params.get(inst.prop) + if (!instance || instance.size() == 0 || !this.doesAnswerMatch(instance, params.get(inst.prop)?.toLowerCase())) { + rtnValidations[idx].errorMsg = messageSource.getMessage('spring.security.ui.securityQuestions.extraValidationString.notequal', null, 'Not Equal', LocaleContextHolder.getLocale()) + isvalid = false + } + idx++ + } + + [isvalid, rtnValidations] + } + + @Transactional + def verifyRegistration(String token) { + def conf = SpringSecurityUtils.securityConfig + RegistrationCode registrationCode = token ? RegistrationCode.findByToken(token) : null + def registerPostRegisterUrl = conf.ui.register.postRegisterUrl ?: '' + def successHandlerDefaultTargetUrl = conf.successHandler.defaultTargetUrl ?: '/' + + if (!registrationCode) { + return [flashType: 'error', flashmsg: 'spring.security.ui.register.badCode', redirectmsg: successHandlerDefaultTargetUrl] + } + def user = this.finishRegistration(registrationCode) + if (!user || user.hasErrors()) { + return [flashType: 'error', flashmsg: 'spring.security.ui.register.badCode', redirectmsg: successHandlerDefaultTargetUrl] + } + [flashType: 'message', flashmsg: 'spring.security.ui.register.complete', redirectmsg: registerPostRegisterUrl ?: successHandlerDefaultTargetUrl] + } + + @Transactional + def finishRegistration(RegistrationCode registrationCode) { + + def user = findUserByUsername(registrationCode.username) + if (!user) { + return + } + + save accountLocked: false, user, 'finishRegistration', transactionStatus + if (!user.hasErrors()) { + addRoles user, registerDefaultRoleNames + delete registrationCode, 'finishRegistration', transactionStatus + springSecurityService.reauthenticate registrationCode.username + } + + user + } + + @Transactional + RegistrationCode sendForgotPasswordMail(String username) { + sendForgotPasswordMail(username, null, null, false) + } + + @Transactional + RegistrationCode sendForgotPasswordMail(String username, String emailAddress, Boolean sendMail) { + sendForgotPasswordMail(username, emailAddress, null, sendMail) + } + + @Transactional + RegistrationCode sendForgotPasswordMail(String username, String emailAddress, Closure emailBodyGenerator) { + sendForgotPasswordMail(username, emailAddress, emailBodyGenerator, true) + } + + @Transactional + RegistrationCode sendForgotPasswordMail(String username, String emailAddress, Closure emailBodyGenerator, Boolean sendMail) { + + RegistrationCode registrationCode = save(username: username, RegistrationCode, 'sendForgotPasswordMail', transactionStatus) + if (!registrationCode.hasErrors() && sendMail) { + uiMailStrategy.sendForgotPasswordMail( + to: emailAddress, + from: forgotPasswordEmailFrom, + subject: forgotPasswordEmailSubject, + html: emailBodyGenerator(registrationCode.token)) + } + + registrationCode + } + + @Transactional + def resetPassword(ResetPasswordCommand command, RegistrationCode registrationCode) { + + def user = findUserByUsername(registrationCode.username) + save password: encodePassword(command.password), user, 'resetPassword', transactionStatus + + if (!user.hasErrors()) { + delete registrationCode, 'resetPassword', transactionStatus + springSecurityService.reauthenticate registrationCode.username + } + + user + } + + @Transactional + def saveRole(Map properties) { + save properties, Role, 'saveRole', transactionStatus + } + + @Transactional + void updateRole(Map properties, role) { + + updateKeys properties, role + + try { + springSecurityService.updateRole role, properties + } + catch (e) { + uiErrorsStrategy.handleException e, role, properties, this, 'updateRole', transactionStatus + return + } + + if (role.hasErrors()) { + uiErrorsStrategy.handleValidationErrors role, this, 'updateRole', transactionStatus + } + } + + @Transactional + void deleteRole(role) { + try { + UserRole.removeAll role + springSecurityService.deleteRole role + } + catch (e) { + uiErrorsStrategy.handleException e, role, null, this, 'deleteRole', transactionStatus + } + } + + @Transactional + def saveRequestmap(Map properties) { + save properties, Requestmap, 'saveRequestmap', transactionStatus + } + + @Transactional + void updateRequestmap(Map properties, requestmap) { + + save properties, requestmap, 'updateRequestmap', transactionStatus + + if (!requestmap.hasErrors()) { + springSecurityService.clearCachedRequestmaps() + } + } + + @Transactional + void deleteRequestmap(requestmap) { + delete requestmap, 'deleteRequestmap', transactionStatus + springSecurityService.clearCachedRequestmaps() + } + + @Transactional + def saveUser(Map properties, List roleNames, String password) { + + def user = uiPropertiesStrategy.setProperties(properties, User, transactionStatus) + + if (password) { + updatePassword user, password, transactionStatus + } + + save [:], user, 'saveUser', transactionStatus + if (!user.hasErrors()) { + addRoles user, roleNames + } + + user + } + + @Transactional + void updateUser(Map properties, user, List roleNames) { + String oldPassword = uiPropertiesStrategy.getProperty(user, 'password') + + uiPropertiesStrategy.setProperties properties, user, transactionStatus + if (properties.password && properties.password != oldPassword) { + updatePassword user, properties.password, transactionStatus + } + + save [:], user, 'updateUser', transactionStatus + if (user.hasErrors()) { + return + } + + updateUserRoles user, roleNames, transactionStatus + removeUserFromCache user + } + + @Transactional + void deleteUser(user) { + UserRole.removeAll user + delete user, 'deleteUser', transactionStatus + removeUserFromCache user + } + + protected void addRoles(user, List roleNames) { + String authorityNameField = uiPropertiesStrategy.paramNameToPropertyName('authority', 'role') + + try { + for (String roleName in roleNames) { + UserRole.create user, Role.findWhere((authorityNameField): roleName) + } + } + catch (e) { + uiErrorsStrategy.handleException e, user, null, this, 'addRoles', transactionStatus + } + } + + @Transactional + protected void addUserRoles(user, Set rolesToAdd) { + if (!user || !rolesToAdd) { + return + } + + List roleNames = [] + + rolesToAdd.each { role -> + roleNames << role.authority + } + + addRoles(user, roleNames) + } + + protected void updateUserRoles(user, List roleNames, TransactionStatus transactionStatus) { + String authorityNameField = uiPropertiesStrategy.paramNameToPropertyName('authority', 'role') + + try { + String dynamicFinder = 'findAllBy'.concat(authorityNameField.capitalize()).concat('InList') + List selectedRoles = Role."${dynamicFinder}"(roleNames) + Set originalRoles = user.authorities + Set rolesToRemove = originalRoles - selectedRoles + Set rolesToAdd = selectedRoles - originalRoles + + removeUserRoles(user, rolesToRemove) + addUserRoles(user, rolesToAdd) + } + catch (e) { + uiErrorsStrategy.handleException e, user, null, this, 'updateUserRoles', transactionStatus + } + } + + protected void removeUserRoles(user, Set rolesToRemove) { + if (!user || !rolesToRemove) { + return + } + + try { + rolesToRemove.each { role -> + removeUserRoleAndReturnBoolean(user, role) + } + } + catch (e) { + uiErrorsStrategy.handleException e, user, null, this, 'removeUserRoles', transactionStatus + } + } + + @Transactional + boolean removeUserRoleAndReturnBoolean(def user, def role) { + UserRole.remove user, role + } + + @Transactional + Number removeUserRole(def u, def r) { + UserRole.where { user == u && role == r }.deleteAll() + } + + protected void removeUserFromCache(user) { + userCache.removeUserFromCache uiPropertiesStrategy.getProperty(user, 'username') + } + + protected void delete(instance, String methodName, TransactionStatus transactionStatus) { + + assert transactionStatus + + try { + instance.delete() + } + catch (e) { + uiErrorsStrategy.handleException e, instance, null, this, methodName, transactionStatus + } + } + + Map, Map> findClassMappings() { + def conf = SpringSecurityUtils.securityConfig + + def mappings = [:] + + def userLookup = conf.userLookup + mappings[User] = [ + username: userLookup.usernamePropertyName ?: '', + enabled: userLookup.enabledPropertyName ?: '', + password: userLookup.passwordPropertyName ?: '', + authorities: userLookup.authoritiesPropertyName ?: '', + accountExpired: userLookup.accountExpiredPropertyName ?: '', + accountLocked: userLookup.accountLockedPropertyName ?: '', + passwordExpired: userLookup.passwordExpiredPropertyName ?: ''].asImmutable() + + mappings[Role] = [authority: conf.authority.nameField ?: ''].asImmutable() + + def requestMap = conf.requestMap + mappings[Requestmap] = [ + url: requestMap.urlField ?: '', + configAttribute: requestMap.configAttributeField ?: '', + httpMethod: requestMap.httpMethodField ?: ''].asImmutable() + + mappings + } + + def getProperty(instance, String paramName) { + if (!instance) return + + if (paramName.endsWith('.id')) { + paramName = paramName[0..-4] + } + + def value = instance + String currentPath = '' + for (String paramNamePart in paramName.split('\\.')) { + currentPath += paramNamePart + + String classPropertyName = GrailsNameUtils.getPropertyName( + unproxy(value.getClass()).name) + + String propertyName = uiPropertiesStrategy.paramNameToPropertyName( + paramNamePart, classPropertyName) + + try { + if (instance.metaClass.getMetaProperty(propertyName)?.getter) { + value = value[propertyName] + if (value == null) return + } else { + log.error 'Attempted to read non-existent property {} in {}', currentPath as String, instance as String + return + } + } + catch (e) { + throw new InvalidValueException(propertyName, paramNamePart, value, e) + } + + currentPath += '.' + } + + value + } + + String paramNameToPropertyName(String paramName, String controllerName) { + // Properties in the ACL classes and RegistrationCode aren't currently + // configurable, and the PersistentLogin class is generated but its + // properties also aren't configurable. Since this method is only by + // the controllers to be able to hard-code param names and lookup the + // actual domain class property name we can short-circuit the logic here. + // Additionally there's no need to support nested properties (e.g. + // 'aclClass.className' for AclObjectIdentity search) since those are + // not used in GSP for classes with configurable properties. + + if (!paramName) { + return paramName + } + + Class clazz = domainClassesByControllerName[controllerName] + String name + if (clazz) { + String key = paramName + if (paramName.endsWith('.id')) { + key = paramName[0..-4] + } + name = classMappings[clazz][key] + } + name ?: paramName + } + + def setProperties(Map data, instanceOrClass, TransactionStatus transactionStatus) { + if (instanceOrClass == null) return + + updateKeys data, instanceOrClass + + def instance + try { + if (instanceOrClass instanceof Class) { + instance = instanceOrClass.newInstance(data) + } else { + instance = instanceOrClass + instance.properties = data + } + } + catch (e) { + uiErrorsStrategy.handleException e, instance, data, this, 'setProperties', transactionStatus + } + + instance + } + + protected void updateKeys(Map data, instanceOrClass) { + Class clazz + if (instanceOrClass instanceof Class) { + clazz = instanceOrClass + } else { + clazz = instanceOrClass.getClass() + } + + Map mappings = classMappings[unproxy(clazz)] + if (!mappings) return + + def removed = [:] + ([] + data.keySet()).each { String paramName -> + String mappedKey = mappings[paramName] + if (mappedKey && mappedKey != paramName) { + removed[mappedKey] = data.remove(paramName) + } + } + data.putAll removed + } + + void handleValidationErrors(bean, source, String operation, TransactionStatus transactionStatus) { + log.warn('Problem in {} at "{}" {} {} : {} {}', + source as String, + operation as String, + isAttached(bean) ? 'updating' : 'creating', + bean.getClass().simpleName, + bean as String, + beanErrors(bean)) + rollbackAndDiscard bean, transactionStatus + } + + String beanErrors(bean) { + StringBuilder message = new StringBuilder() + def locale = LocaleContextHolder.getLocale() + for (fieldErrors in bean.errors) { + for (error in fieldErrors.allErrors) { + message << '\n\t' << messageSource.getMessage(error, locale) + } + } + message.toString() + } + + void handleException(Throwable t, bean, Map properties, source, String operation, + TransactionStatus transactionStatus) { + + boolean attached = bean && !(bean instanceof CommandObject) && bean.attached + + def message = new StringBuilder() + message << 'Problem in ' << source << ' at "' << operation + + if (bean) { + message << '" with bean ' << bean.getClass().simpleName << ': ' << bean + } + + log.error message.toString(), t + + if (transactionStatus) { + rollbackAndDiscard bean, transactionStatus + } + } + + protected boolean isAttached(bean) { + bean && !(bean instanceof CommandObject) && bean.attached + } + + protected void rollbackAndDiscard(bean, TransactionStatus transactionStatus) { + + if (bean && !(bean instanceof CommandObject)) { + bean.discard() + } + + transactionStatus.setRollbackOnly() + } + + String encodePassword(String password) { + if (encodePassword) { + password = springSecurityService.encodePassword(password) + } + password + } + + Closure buildProjection(String path, String criterionMethod, List args) { + + log.debug "Building projection for path '{}': {}({})", path, criterionMethod, args.toString() + + def invoker = { String projectionName, Closure subcriteria -> + log.trace 'Invoking projection {}', projectionName + delegate."$projectionName"(subcriteria) + } + + def closure = { -> + log.trace 'Invoking criterion {}({})', criterionMethod, args as String + delegate."$criterionMethod"(args) + } + + for (String projectionName in (path.split('\\.').reverse())) { + closure = invoker.clone().curry(projectionName, closure) + } + + closure + } + + def runCriteria(Class clazz, List> criterias, Map paginateParams) { + clazz.createCriteria().list(paginateParams) { + for (Closure criteria in criterias) { + criteria.delegate = delegate + criteria() + } + } + } + + protected void updatePassword(user, String password, TransactionStatus transactionStatus) { + uiPropertiesStrategy.setProperties password: encodePassword(password), user, transactionStatus + } + + protected Class unproxy(Class clazz) { + Class current = clazz + while (isProxy(current)) { + current = current.superclass + } + current + } + + protected boolean isProxy(Class clazz) { + if (clazz.superclass == Object) { + return false + } + + if (ClassUtils.isCglibProxyClass(clazz)) { + return true + } + + // Javassist + clazz.interfaces.any { it.name.contains('org.hibernate.proxy.HibernateProxy') } + } + + protected save(Map data, instanceOrClass, String methodName, transactionStatus, Closure callback = null) { + + assert transactionStatus + + def instance + try { + instance = uiPropertiesStrategy.setProperties(data, instanceOrClass, transactionStatus) + + if (callback) { + callback instance + } + + instance.save() + if (instance.hasErrors()) { + uiErrorsStrategy.handleValidationErrors instance, this, methodName, transactionStatus + } + } + catch (e) { + uiErrorsStrategy.handleException e, instance, data, this, methodName, transactionStatus + } + + instance + } + + /** + * When databinding and an object reference id isn't valid, the property will be a new + * 'transient' instance. If there are no validation errors, Hibernate will complain when + * flushing (typically with committing the transaction) 'an unsaved transient instance', + * so we remove the instance here before calling save(). If the property is nullable the + * update will succeed and the caller can handle the problem, and if it's not nullable + * this will trigger a validation error which is easier to handle than the exception + * that Hibernate throws. + * + * @param bean the bean (in practice only an AclEntry or AclObjectIdentity) since the + * other domain classes don't have FK references + * @param propertyName the property name of the child object + */ + protected void removeIfTransient(bean, String... propertyNames) { + def discardedNames = [] + for (propertyName in propertyNames) { + def property = bean[propertyName] + if (property && !property.attached) { + bean[propertyName] = null + discardedNames << propertyName + } + } + + if (discardedNames) { + log.warn 'Discarding transient properties {} from {}', discardedNames as String, bean as String + } + } + + protected findUserByUsername(String username) { + User.findWhere((classMappings[User].username): username) + } + + protected boolean encodePassword + protected String forgotPasswordEmailFrom + protected String forgotPasswordEmailSubject + protected List registerDefaultRoleNames + + protected Class AclClass + protected Class AclEntry + protected Class AclObjectIdentity + protected Class AclSid + protected Class Requestmap + protected Class Role + protected Class User + protected Class UserRole + + protected Map> domainClassesByControllerName = [:] + + void initialize() { + def conf = SpringSecurityUtils.securityConfig + + def encode = conf.ui.encodePassword + encodePassword = encode instanceof Boolean ? encode : false + + forgotPasswordEmailFrom = conf.ui.forgotPassword.emailFrom ?: '' + forgotPasswordEmailSubject = conf.ui.forgotPassword.emailSubject ?: messageSource ? messageSource.getMessage('spring.security.ui.forgotPassword.email.subject', [].toArray(), 'Password Reset', LocaleContextHolder.locale) : '' ?: '' + + registerDefaultRoleNames = conf.ui.register.defaultRoleNames ?: [] + + def getDomainClassClass = { String name -> + if (name) return grailsApplication.getDomainClass(name)?.clazz + } + + AclClass = getDomainClassClass(ACL_CLASS_NAME) + AclEntry = getDomainClassClass(ACL_ENTRY_NAME) + AclObjectIdentity = getDomainClassClass(ACL_OBJECT_IDENTITY_NAME) + AclSid = getDomainClassClass(ACL_SID_NAME) + Requestmap = getDomainClassClass(conf.requestMap.className) + Role = getDomainClassClass(conf.authority.className) + User = getDomainClassClass(conf.userLookup.userDomainClassName) + UserRole = getDomainClassClass(conf.userLookup.authorityJoinClassName) + + domainClassesByControllerName = [requestmap: Requestmap, role: Role, user: User] + + classMappings = uiPropertiesStrategy.findClassMappings().asImmutable() + } +} diff --git a/grails-spring-security/ui/plugin/grails-app/taglib/grails/plugin/springsecurity/ui/SecurityUiTagLib.groovy b/grails-spring-security/ui/plugin/grails-app/taglib/grails/plugin/springsecurity/ui/SecurityUiTagLib.groovy new file mode 100644 index 00000000000..2b71e00a18a --- /dev/null +++ b/grails-spring-security/ui/plugin/grails-app/taglib/grails/plugin/springsecurity/ui/SecurityUiTagLib.groovy @@ -0,0 +1,911 @@ +/* + * 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.ui + +import grails.plugin.springsecurity.SpringSecurityUtils +import grails.plugin.springsecurity.ui.strategy.PropertiesStrategy +import org.grails.web.servlet.mvc.SynchronizerTokensHolder + +/** + * @author Burt Beckwith + */ +class SecurityUiTagLib { + + static namespace = 's2ui' + + /** Dependency injection for the 'uiPropertiesStrategy' bean. */ + PropertiesStrategy uiPropertiesStrategy + + /** + * @attr paramName REQUIRED the property to search on + * @attr focus if true, set the focus to the field at document ready (defaults to true) + * @attr minLength the minimum number of chars to type before search starts + */ + def ajaxSearch = { attrs -> + attrs = [:] + attrs + + String paramName = getRequiredAttribute(attrs, 'paramName', 'ajaxSearch') + int minLength = (attrs.remove('minLength') as Integer) ?: 3 + + String focus = attrs.remove('focus') != 'false' ? '.focus()' : '' + + writeDocumentReady out, """\ + \$("#$paramName")${focus}.autocomplete({ + minLength: $minLength, + cache: false, + source: "${createLink('ajaxSearch', null, [paramName: paramName])}" + });""" + } + + /** + * @attr name REQUIRED the HTML element name + * @attr labelCodeDefault the default to display if there's no message for the i18n code + */ + def checkboxRow = { attrs -> + attrs = [:] + attrs + + def bean = beanFromModel() + String name = getRequiredAttribute(attrs, 'name', 'checkboxRow') + String labelCodeDefault = attrs.remove('labelCodeDefault') + + out << """ + + + + + + ${checkBox([name: name, value: uiPropertiesStrategy.getProperty(bean, name)] + attrs)} + ${fieldErrors(bean, name)} + + """ + } + + /** + * @attr name REQUIRED the HTML element name + * @attr labelCodeDefault the default to display if there's no message for the i18n code + */ + def dateFieldRow = { attrs -> + attrs = [:] + attrs + + def bean = beanFromModel() + String labelCodeDefault = attrs.remove('labelCodeDefault') + String name = getRequiredAttribute(attrs, 'name', 'dateFieldRow') + + def value = formatDate(date: uiPropertiesStrategy.getProperty(bean, name), + formatName: 'spring.security.ui.dateFormatGsp') + + out << """ + + + + + + ${textField([name: name, value: value, maxlength: '20'] + attrs)} + ${fieldErrors(bean, name)} + + """ + + writeDocumentReady out, "\t\$('#$name').datepicker({ dateFormat: '${message(code: 'spring.security.ui.dateFormatJs')}' });" + } + + /** + * @attr src REQUIRED the src + */ + def deferredScript = { attrs -> + attrs = [:] + attrs + + String src = getRequiredAttribute(attrs, 'src', 'deferredScript') + + def deferred = request.s2uiDeferredScripts + if (!deferred) { + deferred = request.s2uiDeferredScripts = [] + } + deferred << src + } + + /** + */ + def deferredScripts = { attrs -> + request.s2uiDeferredScripts.each { out << asset.javascript(src: it) << '\n' } + out << asset.deferredScripts().replaceAll('