commit 2db4aaddbe6bcc59cf257afc5e50c5c6f61779c7 Author: AFaust Date: Wed Aug 21 00:01:40 2019 +0200 Initial version diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a6ee4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.settings/ +bin/ +.project +.classpath + +target/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..401bdeb --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +sudo: false +language: java +jdk: + - openjdk8 +cache: + directories: + - $HOME/.m2 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ace4d69 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Acosix GmbH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..92b0afa --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +[![Build Status](https://travis-ci.org/Acosix/alfresco-keycloak.svg?branch=master)](https://travis-ci.org/Acosix/alfresco-keycloak) + +# About + +This addon aims to provide a Keycloak-related extensions / customisations to the out-of-the-box Alfresco authentication and authorization functionalities for the Alfresco Repository and Share tiers. + +## Compatbility + +This module is built to be compatible with Alfresco 6.0 and above. It may be used on either Community or Enterprise Edition. + +## Features + +At this time, only the Share sub-module of this project provides a feature enhancement. + +The Share sub-module provides a Keycloak-based filter and customisations that support: + +- enhancement of the login dialog to allow users to perform an external authentication via a Keycloak server as an alternative to the password-based login +- enforcement of an external authentication via a Keycloak server for all users via a SSO filter enhancement +- back-channel logout and other operations actively initiated by a Keycloak server, e.g. invalidating all authentication tokens issued after a specific point in time +- active logout which also logs out the user centrally on the Keycloak server + +# Build + +This project uses a Maven build using templates from the [Acosix Alfresco Maven](https://github.com/Acosix/alfresco-maven) project and produces module AMPs, regular Java *classes* JARs, JavaDoc and source attachment JARs, as well as installable (Simple Alfresco Module) JAR artifacts for the Alfresco Content Services and Share extensions. If the installable JAR artifacts are used for installing this module, developers / users are advised to consult the 'Dependencies' section of this README. + +## Maven toolchains + +By inheritance from the Acosix Alfresco Maven framework, this project uses the [Maven Toolchains plugin](http://maven.apache.org/plugins/maven-toolchains-plugin/) to allow potential cross-compilation against different Java versions. This plugin is used to avoid potentially inconsistent compiler and library versions compared to when only the source/target compiler options of the Maven compiler plugin are set, which (as an example) has caused issues with some Alfresco releases in the past where Alfresco compiled for Java 7 using the Java 8 libraries. +In order to build the project it is necessary to provide a basic toolchain configuration via the user specific Maven configuration home (usually ~/.m2/). That file (toolchains.xml) only needs to list the path to a compatible JDK for the Java version required by this project. The following is a sample file defining a Java 7 and 8 development kit. + +```xml + + + + jdk + + 1.8 + oracle + + + C:\Program Files\Java\jdk1.8.0_112 + + + + jdk + + 1.7 + oracle + + + C:\Program Files\Java\jdk1.7.0_80 + + + +``` + +The master branch requires Java 8. + +## Docker-based integration tests + +In a default build using ```mvn clean install```, this project will build the extension for Alfresco Content Services, executing regular unit-tests without running integration tests. The integration tests of this project are based on Docker and require a Docker engine to run the necessary components (PostgreSQL database as well as Alfresco Content Services). Since a Docker engine may not be available in all environments of interested community members / collaborators, the integration tests have been made optional. A full build, including integration tests, can be run by executing + +``` +mvn clean install -Ddocker.tests.enabled=true +``` + +This project currently does not contain any integration tests, as a proper setup which includes Keycloak has not yet been achieved. + +## Dependencies + +This module depends on the following projects / libraries: + +- various [Keycloak]https://github.com/keycloak/keycloak) adapter and client libraries (Apache License, Version 2.0) + - keycloak-adapter-core + - keycloak-servlet-adapter-spi + - keycloak-servlet-filter-adapter + - keycloak-authz-client +- Acosix Alfresco Utility (Apache License, Version 2.0) - core extension + +The Share AMP of this project includes all the Keycloak library JARs that the extension depends on. The Acosix Alfresco Utility project provides the core extension for Alfresco Content Services as a separate artifact from the full module, which needs to be installed in Alfresco Content Services before the AMP of this project can be installed. + +When the installable JAR produced by the build of this project is used for installation, the developer / user is responsible to either manually install all the required components / libraries provided by the listed projects, or use a build system to collect all relevant direct / transitive dependencies. +**Note**: The Acosix Alfresco Utility project is also built using templates from the Acosix Alfresco Maven project, and as such produces similar artifacts. Automatic resolution and collection of (transitive) dependencies using Maven / Gradle will resolve the Java *classes* JAR as a dependency, and **not** the installable (Simple Alfresco Module) variant. It is recommended to exclude Acosix Alfresco Utility from transitive resolution and instead include it directly / explicitly. + +## Using SNAPSHOT builds + +In order to use a pre-built SNAPSHOT artifact published to the Open Source Sonatype Repository Hosting site, the artifact repository may need to be added to the POM, global settings.xml or an artifact repository proxy server. The following is the XML snippet for inclusion in a POM file. + +```xml + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + true + + + +``` \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..58b8c82 --- /dev/null +++ b/pom.xml @@ -0,0 +1,200 @@ + + + + 4.0.0 + + + de.acosix.alfresco.maven + de.acosix.alfresco.maven.project.parent-6.1.2 + 1.2.0 + + + de.acosix.alfresco.keycloak + de.acosix.alfresco.keycloak.parent + 1.0.0 + pom + + Acosix Alfresco Keycloak - Parent + Addon to provide Keycloak-related customisations / extensions to out-of-the-box Alfresco authentication and authorization functionality + https://github.com/Acosix/alfresco-keycloak + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + + + + + scm:git:git@github.com:Acosix/alfresco-keycloak.git + scm:git:git@github.com:Acosix/alfresco-keycloak.git + git@github.com:Acosix/alfresco-keycloak.git + + + + + AFaust + Axel Faust + axel.faust@acosix.de + Acosix GmbH + + Founder + Architect + Developer + + + twitter.com/ReluctantBird83 + + + + + + acosix/keycloak + acosix.keycloak + acosix-keycloak + + + 1.8 + 1.8 + + 6.0.1 + + 4.5.1 + 4.4.3 + + 1.0.7.0 + + + + + + + org.keycloak + keycloak-common + ${keycloak.version} + + + + org.keycloak + keycloak-core + ${keycloak.version} + + + + org.keycloak + keycloak-adapter-core + ${keycloak.version} + + + + org.keycloak + keycloak-adapter-spi + ${keycloak.version} + + + + org.keycloak + keycloak-servlet-adapter-spi + ${keycloak.version} + + + + org.keycloak + keycloak-servlet-filter-adapter + ${keycloak.version} + + + + org.keycloak + keycloak-authz-client + ${keycloak.version} + + + + + org.apache.httpcomponents + httpclient + ${apache.httpclient.version} + provided + + + + org.apache.httpcomponents + httpcore + ${apache.httpcore.version} + provided + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.common + ${acosix.utility.version} + provided + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.core.repo + ${acosix.utility.version} + provided + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.core.repo + ${acosix.utility.version} + installable + test + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.core.share + ${acosix.utility.version} + provided + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.core.share + ${acosix.utility.version} + installable + test + + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + true + + + + + + + + + + repository + share + + \ No newline at end of file diff --git a/repository/file-mapping.properties b/repository/file-mapping.properties new file mode 100644 index 0000000..7237cc9 --- /dev/null +++ b/repository/file-mapping.properties @@ -0,0 +1,2 @@ +include.default=true +/web=/ \ No newline at end of file diff --git a/repository/module.properties b/repository/module.properties new file mode 100644 index 0000000..b0ef680 --- /dev/null +++ b/repository/module.properties @@ -0,0 +1,6 @@ +module.id=${moduleId} +module.title=${project.name} +module.description=${project.description} +module.version=${noSnapshotVersion} + +module.repo.version.min=5 \ No newline at end of file diff --git a/repository/pom.xml b/repository/pom.xml new file mode 100644 index 0000000..2037b21 --- /dev/null +++ b/repository/pom.xml @@ -0,0 +1,49 @@ + + + + 4.0.0 + + + de.acosix.alfresco.keycloak + de.acosix.alfresco.keycloak.parent + 1.0.0 + + + de.acosix.alfresco.keycloak.repo + Acosix Alfresco Keycloak - Repository Module + + + + + + + org.alfresco + alfresco-repository + + + + + + + + io.fabric8 + docker-maven-plugin + + + + \ No newline at end of file diff --git a/repository/src/main/config/alfresco-global.properties b/repository/src/main/config/alfresco-global.properties new file mode 100644 index 0000000..e69de29 diff --git a/repository/src/main/config/log4j.properties b/repository/src/main/config/log4j.properties new file mode 100644 index 0000000..18249c2 --- /dev/null +++ b/repository/src/main/config/log4j.properties @@ -0,0 +1 @@ +log4j.logger.${project.artifactId}=INFO \ No newline at end of file diff --git a/repository/src/main/config/module-context.xml b/repository/src/main/config/module-context.xml new file mode 100644 index 0000000..3a5432d --- /dev/null +++ b/repository/src/main/config/module-context.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/repository/src/main/globalConfig/.gitkeep b/repository/src/main/globalConfig/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/repository/src/main/messages/.gitkeep b/repository/src/main/messages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/repository/src/main/resources/.gitkeep b/repository/src/main/resources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/repository/src/main/webapp/.gitkeep b/repository/src/main/webapp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/repository/src/main/webscripts/.gitkeep b/repository/src/main/webscripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/share/file-mapping.properties b/share/file-mapping.properties new file mode 100644 index 0000000..7237cc9 --- /dev/null +++ b/share/file-mapping.properties @@ -0,0 +1,2 @@ +include.default=true +/web=/ \ No newline at end of file diff --git a/share/module.properties b/share/module.properties new file mode 100644 index 0000000..7a052cb --- /dev/null +++ b/share/module.properties @@ -0,0 +1,4 @@ +module.id=${moduleId} +module.title=${project.name} +module.description=${project.description} +module.version=${noSnapshotVersion} \ No newline at end of file diff --git a/share/pom.xml b/share/pom.xml new file mode 100644 index 0000000..8e7cb48 --- /dev/null +++ b/share/pom.xml @@ -0,0 +1,113 @@ + + + + 4.0.0 + + + de.acosix.alfresco.keycloak + de.acosix.alfresco.keycloak.parent + 1.0.0 + + + de.acosix.alfresco.keycloak.share + Acosix Alfresco Keycloak - Share Module + + + + + org.alfresco + share + classes + + + + javax.servlet + javax.servlet-api + + + + org.keycloak + keycloak-adapter-core + + + + org.keycloak + keycloak-servlet-adapter-spi + + + + org.keycloak + keycloak-servlet-filter-adapter + + + + org.keycloak + keycloak-authz-client + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.core.repo + installable + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.core.share + + + + de.acosix.alfresco.utility + de.acosix.alfresco.utility.core.share + installable + + + + ${project.groupId} + de.acosix.alfresco.keycloak.repo + ${project.version} + installable + test + + + + + + + + + de.thetaphi + forbiddenapis + + + **/KeycloakAdapterConfigElement.class + + + + + + + + + net.alchim31.maven + yuicompressor-maven-plugin + + + + + \ No newline at end of file diff --git a/share/src/main/assembly/amp.xml b/share/src/main/assembly/amp.xml new file mode 100644 index 0000000..2e014f8 --- /dev/null +++ b/share/src/main/assembly/amp.xml @@ -0,0 +1,55 @@ + + + + amp + + amp + + false + + assemblies/amp-lib-component.xml + assemblies/amp-config-component.xml + assemblies/amp-messages-component.xml + assemblies/amp-repo-webscript-component.xml + assemblies/amp-surf-webscript-component.xml + assemblies/amp-templates-component.xml + assemblies/amp-webapp-component.xml + + + + ${project.basedir} + + + *.properties + + true + crlf + + + + + lib + + org.keycloak:* + org.jboss.logging:* + org.bouncycastle:* + com.fasterxml.jackson.core:* + + + + diff --git a/share/src/main/config/default-config.xml b/share/src/main/config/default-config.xml new file mode 100644 index 0000000..263d90a --- /dev/null +++ b/share/src/main/config/default-config.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + true + true + false + + 8443 + 10485760 + 1000 + + + http://localhost:8180/auth + alfresco + alfresco + none + + false + + secret + + + + + + + + + 60000 + + + + \ No newline at end of file diff --git a/share/src/main/config/log4j.properties b/share/src/main/config/log4j.properties new file mode 100644 index 0000000..18249c2 --- /dev/null +++ b/share/src/main/config/log4j.properties @@ -0,0 +1 @@ +log4j.logger.${project.artifactId}=INFO \ No newline at end of file diff --git a/share/src/main/config/module-config.xml b/share/src/main/config/module-config.xml new file mode 100644 index 0000000..ef2339d --- /dev/null +++ b/share/src/main/config/module-config.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/share/src/main/config/module-context.xml b/share/src/main/config/module-context.xml new file mode 100644 index 0000000..17a0e3c --- /dev/null +++ b/share/src/main/config/module-context.xml @@ -0,0 +1,77 @@ + + + + + + + + classpath:alfresco/share-config.xml + + + + + classpath:alfresco/web-extension/share-config-custom.xml + jar:*!/META-INF/share-config-custom.xml + + + + + classpath:alfresco/module/${moduleId}/default-config.xml + + + + + + + + + classpath:alfresco/module/${moduleId}/module-config.xml + + + + + + + + + + + + + + + + + alfresco-api + alfresco-feed + + + + + + + + + + + + + + diff --git a/share/src/main/config/share-global.properties b/share/src/main/config/share-global.properties new file mode 100644 index 0000000..e69de29 diff --git a/share/src/main/globalConfig/site-data/extensions/acosix-keycloak-extension.xml b/share/src/main/globalConfig/site-data/extensions/acosix-keycloak-extension.xml new file mode 100644 index 0000000..785fa79 --- /dev/null +++ b/share/src/main/globalConfig/site-data/extensions/acosix-keycloak-extension.xml @@ -0,0 +1,41 @@ + + + + + + ${moduleId} - Base Extensions + ${project.name} - Base Extensions + ${noSnapshotVersion} + true + + + + org.alfresco + de.acosix.keycloak.customisations + + + + + org.alfresco.share.pages + de.acosix.keycloak.customisations.share.header + + share-header + + + + + diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElement.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElement.java new file mode 100644 index 0000000..4ae883a --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElement.java @@ -0,0 +1,457 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.config; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.util.EqualsHelper; +import org.alfresco.util.ParameterCheck; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.springframework.extensions.config.ConfigElement; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import de.acosix.alfresco.utility.share.config.BaseCustomConfigElement; +import de.acosix.alfresco.utility.share.config.ConfigValueHolder; + +/** + * @author Axel Faust + */ +public class KeycloakAdapterConfigElement extends BaseCustomConfigElement +{ + + public static final String NAME = "keycloak-adapter-config"; + + private static final long serialVersionUID = -7211927327179092723L; + + private static final Map FIELD_BY_CONFIG_NAME; + + private static final Map> VALUE_TYPE_BY_CONFIG_NAME; + + private static final List CONFIG_NAMES; + + static + { + final Map fieldByConfigName = new HashMap<>(); + final Map> valueTypeByConfigName = new HashMap<>(); + final List configNames = new ArrayList<>(); + + final Set> supportedValueTypes = new HashSet<>(Arrays.asList(String.class, Map.class)); + final Map, Class> primitiveWrapperTypeMap = new HashMap<>(); + final Class[] wrapperTypes = { Integer.class, Long.class, Boolean.class, Short.class, Byte.class, Character.class, Float.class, + Double.class }; + final Class[] primitiveTypes = { int.class, long.class, boolean.class, short.class, byte.class, char.class, float.class, + double.class }; + for (int i = 0; i < primitiveTypes.length; i++) + { + supportedValueTypes.add(primitiveTypes[i]); + supportedValueTypes.add(wrapperTypes[i]); + primitiveWrapperTypeMap.put(primitiveTypes[i], wrapperTypes[i]); + } + + Class cls = AdapterConfig.class; + while (cls != null && !Object.class.equals(cls)) + { + final Field[] fields = cls.getDeclaredFields(); + for (final Field field : fields) + { + final JsonProperty annotation = field.getAnnotation(JsonProperty.class); + if (annotation != null) + { + final String configName = annotation.value(); + Class valueType = field.getType(); + if (valueType.isPrimitive()) + { + valueType = primitiveWrapperTypeMap.get(valueType); + } + + if (supportedValueTypes.contains(valueType)) + { + fieldByConfigName.put(configName, field); + valueTypeByConfigName.put(configName, valueType); + configNames.add(configName); + } + } + } + + cls = cls.getSuperclass(); + } + + FIELD_BY_CONFIG_NAME = Collections.unmodifiableMap(fieldByConfigName); + VALUE_TYPE_BY_CONFIG_NAME = Collections.unmodifiableMap(valueTypeByConfigName); + CONFIG_NAMES = Collections.unmodifiableList(configNames); + } + + protected final Map configValueByField = new HashMap<>(); + + protected final Set markedAsUnset = new HashSet<>(); + + protected final ConfigValueHolder connectionTimeout = new ConfigValueHolder<>(); + + protected final ConfigValueHolder socketTimeout = new ConfigValueHolder<>(); + + /** + * Creates a new instance of this class. + */ + public KeycloakAdapterConfigElement() + { + super(NAME); + } + + /** + * @return the connectionTimeout + */ + public Long getConnectionTimeout() + { + return this.connectionTimeout.getValue(); + } + + /** + * @param connectionTimeout + * the connectionTimeout to set + */ + public void setConnectionTimeout(final Long connectionTimeout) + { + this.connectionTimeout.setValue(connectionTimeout); + } + + /** + * @return the socketTimeout + */ + public Long getSocketTimeout() + { + return this.socketTimeout.getValue(); + } + + /** + * @param socketTimeout + * the socketTimeout to set + */ + public void setSocketTimeout(final Long socketTimeout) + { + this.socketTimeout.setValue(socketTimeout); + } + + /** + * Checks if a specific field is supported by this config element. + * + * @param fieldName + * the name of the field to check + * @return {@code true} if the field is supported, {@code false} otherwise + */ + public boolean isFieldSupported(final String fieldName) + { + ParameterCheck.mandatoryString("fieldName", fieldName); + final boolean supported = CONFIG_NAMES.contains(fieldName); + return supported; + } + + /** + * Retrieves the expected type of value for a specific field. This operation will never return a class object for primitive types, + * instead replacing those with the class object for the corresponding wrapper type. + * + * @param fieldName + * the name of the field for which to retrieve the type of value + * @return the type of value for the field + */ + public Class getFieldValueType(final String fieldName) + { + if (!this.isFieldSupported(fieldName)) + { + throw new IllegalArgumentException(fieldName + " is not a supported field"); + } + final Class valueType = VALUE_TYPE_BY_CONFIG_NAME.get(fieldName); + return valueType; + } + + /** + * Retrieves the configured value for a specific field. Default values inherent in the {@link AdapterConfig Keycloak classes} are not + * considered by this operation. + * + * @param fieldName + * the name of the field for which to retrieve the value + * @return the currently configured value for the field, or {@code null} if no value has been configured + */ + public Object getFieldValue(final String fieldName) + { + final Object value = this.configValueByField.get(fieldName); + return value; + } + + /** + * Sets the configured value for a specific field. + * + * @param fieldName + * the name of the field for which to set the value + * @param value + * the value of the field to set + * @throws IllegalArgumentException + * if the field is {@link #isFieldSupported(String) not supported} or the type of value does not match the required type + */ + public void setFieldValue(final String fieldName, final Object value) + { + if (!this.isFieldSupported(fieldName)) + { + throw new IllegalArgumentException(fieldName + " is not a supported field"); + } + ParameterCheck.mandatory("value", value); + final Class valueType = VALUE_TYPE_BY_CONFIG_NAME.get(fieldName); + if (!valueType.isInstance(value)) + { + throw new IllegalArgumentException("Value is not an instance of " + valueType); + } + this.configValueByField.put(fieldName, value); + this.markedAsUnset.remove(fieldName); + } + + /** + * Removes the configured value for a specific field. + * + * @param fieldName + * the name of the field for which to set the value + * @param markAsUnset + * {@code true} if the field should be marked as explicitly unset for the purpose of {@link #combine(ConfigElement) merging + * with other config elements}, {@code false} otherwise + * @throws IllegalArgumentException + * if the field is {@link #isFieldSupported(String) not supported} + */ + public void removeFieldValue(final String fieldName, final boolean markAsUnset) + { + if (!this.isFieldSupported(fieldName)) + { + throw new IllegalArgumentException(fieldName + " is not a supported field"); + } + this.configValueByField.remove(fieldName); + if (markAsUnset) + { + this.markedAsUnset.add(fieldName); + } + } + + /** + * Retrieves the explicit {@code unset} flag of a field that may have been set via {@link #removeFieldValue(String, boolean) value + * removal}. + * + * @param fieldName + * the name of the field for which to retrieve the flag + * @return the value of the flag + */ + public boolean isFieldMarkedAsUnset(final String fieldName) + { + if (!this.isFieldSupported(fieldName)) + { + throw new IllegalArgumentException(fieldName + " is not a supported field"); + } + final boolean unset = this.markedAsUnset.contains(fieldName); + return unset; + } + + /** + * Builds an instance of a Keycloak adapter configuration based on the configured values managed by this config element. + * + * @return the adapter configuration instance + */ + public AdapterConfig buildAdapterConfiguration() + { + final AdapterConfig config = new AdapterConfig(); + + try + { + for (final String configName : CONFIG_NAMES) + { + final Field field = FIELD_BY_CONFIG_NAME.get(configName); + + final Object value = this.configValueByField.get(configName); + if (value != null) + { + // TODO Refactor towards use of setter to avoid setAccessible + field.setAccessible(true); + field.set(config, value); + } + } + } + catch (final IllegalAccessException ex) + { + throw new AlfrescoRuntimeException("Error building adapter configuration", ex); + } + + return config; + } + + /** + * {@inheritDoc} + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Override + public ConfigElement combine(final ConfigElement configElement) + { + if (!(configElement instanceof KeycloakAdapterConfigElement)) + { + throw new IllegalArgumentException("Cannot combine with " + configElement); + } + + final KeycloakAdapterConfigElement combined = new KeycloakAdapterConfigElement(); + final KeycloakAdapterConfigElement otherConfigElement = (KeycloakAdapterConfigElement) configElement; + + for (final String configName : CONFIG_NAMES) + { + final Object thisValue = this.getFieldValue(configName); + final Object otherValue = otherConfigElement.getFieldValue(configName); + + if (otherValue != null) + { + Object valueToSet = otherValue; + if (thisValue instanceof Map && otherValue instanceof Map) + { + valueToSet = new HashMap<>((Map) thisValue); + ((Map) valueToSet).putAll((Map) otherValue); + } + else if (otherValue instanceof Map) + { + valueToSet = new HashMap<>((Map) otherValue); + } + combined.setFieldValue(configName, valueToSet); + } + else if (otherConfigElement.isFieldMarkedAsUnset(configName) || this.isFieldMarkedAsUnset(configName)) + { + combined.removeFieldValue(configName, true); + } + else if (thisValue != null) + { + combined.setFieldValue(configName, thisValue instanceof Map ? new HashMap<>((Map) thisValue) : thisValue); + } + } + + if (otherConfigElement.connectionTimeout.isUnset()) + { + combined.connectionTimeout.unset(); + } + else + { + combined.setConnectionTimeout(otherConfigElement.getConnectionTimeout() != null ? otherConfigElement.getConnectionTimeout() + : this.getConnectionTimeout()); + } + + if (otherConfigElement.socketTimeout.isUnset()) + { + combined.socketTimeout.unset(); + } + else + { + combined.setSocketTimeout( + otherConfigElement.getSocketTimeout() != null ? otherConfigElement.getSocketTimeout() : this.getSocketTimeout()); + } + + return combined; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() + { + final StringBuilder builder = new StringBuilder(); + builder.append("KeycloakAdapterConfigElement ["); + builder.append("configValueByField="); + builder.append(this.configValueByField); + builder.append(",markedAsUnset="); + builder.append(this.markedAsUnset); + if (this.connectionTimeout != null) + { + builder.append(",connectionTimeout="); + builder.append(this.connectionTimeout); + } + if (this.connectionTimeout != null) + { + builder.append(",socketTimeout="); + builder.append(this.socketTimeout); + } + builder.append("]"); + return builder.toString(); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() + { + final int prime = 31; + int result = super.hashCode(); + + // use class-consistent order for actual config values + for (final String configName : CONFIG_NAMES) + { + final Object value = this.configValueByField.get(configName); + final int valueHash = value == null ? (this.markedAsUnset.contains(configName) ? -1 : 0) : value.hashCode(); + result = prime * result + valueHash; + } + + result = prime * result + (this.connectionTimeout != null ? this.connectionTimeout.hashCode() : 0); + result = prime * result + (this.socketTimeout != null ? this.socketTimeout.hashCode() : 0); + + return result; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(final Object obj) + { + if (this == obj) + { + return true; + } + if (!super.equals(obj)) + { + return false; + } + if (!(obj instanceof KeycloakAdapterConfigElement)) + { + return false; + } + final KeycloakAdapterConfigElement other = (KeycloakAdapterConfigElement) obj; + if (!EqualsHelper.nullSafeEquals(this.configValueByField, other.configValueByField)) + { + return false; + } + if (!EqualsHelper.nullSafeEquals(this.markedAsUnset, other.markedAsUnset)) + { + return false; + } + if (!EqualsHelper.nullSafeEquals(this.connectionTimeout, other.connectionTimeout)) + { + return false; + } + if (!EqualsHelper.nullSafeEquals(this.socketTimeout, other.socketTimeout)) + { + return false; + } + return true; + } + +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElementReader.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElementReader.java new file mode 100644 index 0000000..2a287db --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAdapterConfigElementReader.java @@ -0,0 +1,142 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.config; + +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.dom4j.Element; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.extensions.config.ConfigElement; +import org.springframework.extensions.config.xml.elementreader.ConfigElementReader; + +/** + * @author Axel Faust + */ +public class KeycloakAdapterConfigElementReader implements ConfigElementReader +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAdapterConfigElementReader.class); + + /** + * {@inheritDoc} + */ + @Override + public ConfigElement parse(final Element element) + { + final KeycloakAdapterConfigElement configElement = new KeycloakAdapterConfigElement(); + + @SuppressWarnings("unchecked") + final Iterator subElementIterator = element.elementIterator(); + while (subElementIterator.hasNext()) + { + final Element subElement = subElementIterator.next(); + final String subElementName = subElement.getName(); + if (configElement.isFieldSupported(subElementName)) + { + final Class valueType = configElement.getFieldValueType(subElementName); + + if (Map.class.equals(valueType)) + { + final Map configMap = new HashMap<>(); + + @SuppressWarnings("unchecked") + final Iterator mapElementIterator = subElement.elementIterator(); + while (mapElementIterator.hasNext()) + { + final Element mapElement = mapElementIterator.next(); + final String key = mapElement.getName(); + final String value = mapElement.getTextTrim(); + + configMap.put(key, value); + } + + configElement.setFieldValue(subElementName, configMap); + } + else + { + final String textTrim = subElement.getTextTrim(); + if (textTrim.isEmpty()) + { + configElement.removeFieldValue(subElementName, true); + } + else if (Number.class.isAssignableFrom(valueType)) + { + try + { + configElement.setFieldValue(subElementName, + valueType.getMethod("valueOf", String.class).invoke(null, textTrim)); + } + catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex) + { + LOGGER.error( + "Number-based value type {} does not provide a publicly accessible, static valueOf to handle conversion of value {}", + valueType, textTrim); + throw new AlfrescoRuntimeException("Failed to convert configuration value " + textTrim, ex); + } + } + else if (Boolean.class.equals(valueType)) + { + configElement.setFieldValue(subElementName, Boolean.valueOf(textTrim)); + } + else if (Character.class.equals(valueType)) + { + if (textTrim.length() > 1) + { + throw new IllegalStateException("Value " + textTrim + " has more than one character"); + } + configElement.setFieldValue(subElementName, new Character(textTrim.charAt(0))); + } + else if (String.class.equals(valueType)) + { + configElement.setFieldValue(subElementName, textTrim); + } + else + { + throw new UnsupportedOperationException("Unsupported value type " + valueType); + } + } + } + else + { + switch (subElementName) + { + // use -1 as dummy value for empty value to signify that empty value has explicitly been set (relevant for merge/combine + // of config) + case "connectionTimeout": + final String prospectiveConnectionTimeout = subElement.getTextTrim(); + configElement.setConnectionTimeout( + prospectiveConnectionTimeout.isEmpty() ? null : Long.valueOf(prospectiveConnectionTimeout)); + break; + case "socketTimeout": + final String prospectiveSocketTimeout = subElement.getTextTrim(); + configElement.setSocketTimeout(prospectiveSocketTimeout.isEmpty() ? null : Long.valueOf(prospectiveSocketTimeout)); + break; + default: + LOGGER.warn("Encountered unsupported Keycloak Adapter config element {}", subElementName); + } + } + } + LOGGER.debug("Read configuration element {} from XML section", configElement); + + return configElement; + } + +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAuthenticationConfigElement.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAuthenticationConfigElement.java new file mode 100644 index 0000000..258d36a --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAuthenticationConfigElement.java @@ -0,0 +1,263 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.config; + +import org.springframework.extensions.config.ConfigElement; + +import de.acosix.alfresco.utility.share.config.BaseCustomConfigElement; +import de.acosix.alfresco.utility.share.config.ConfigValueHolder; + +/** + * + * @author Axel Faust + */ +public class KeycloakAuthenticationConfigElement extends BaseCustomConfigElement +{ + + private static final long serialVersionUID = 8587583775593697136L; + + public static final String NAME = "keycloak-auth-config"; + + protected final ConfigValueHolder enhanceLoginForm = new ConfigValueHolder<>(); + + protected final ConfigValueHolder enableSsoFilter = new ConfigValueHolder<>(); + + protected final ConfigValueHolder forceKeycloakSso = new ConfigValueHolder<>(); + + protected final ConfigValueHolder bodyBufferLimit = new ConfigValueHolder<>(); + + protected final ConfigValueHolder sslRedirectPort = new ConfigValueHolder<>(); + + protected final ConfigValueHolder sessionMapperLimit = new ConfigValueHolder<>(); + + /** + * Creates a new instance of this class. + */ + public KeycloakAuthenticationConfigElement() + { + super(NAME); + } + + /** + * @param enhanceLoginForm + * the enhanceLoginForm to set + */ + public void setEnhanceLoginForm(final Boolean enhanceLoginForm) + { + this.enhanceLoginForm.setValue(enhanceLoginForm); + } + + /** + * @return the enhanceLoginForm + */ + public Boolean getEnhanceLoginForm() + { + return this.enhanceLoginForm.getValue(); + } + + /** + * @param enableSsoFilter + * the enableSsoFilter to set + */ + public void setEnableSsoFilter(final Boolean enableSsoFilter) + { + this.enableSsoFilter.setValue(enableSsoFilter); + } + + /** + * @return the enhanceSsoFilter + */ + public Boolean getEnableSsoFilter() + { + return this.enableSsoFilter.getValue(); + } + + /** + * @param forceKeycloakSso + * the forceKeycloakSso to set + */ + public void setForceKeycloakSso(final Boolean forceKeycloakSso) + { + this.forceKeycloakSso.setValue(forceKeycloakSso); + } + + /** + * @return the forceKeycloakSso + */ + public Boolean getForceKeycloakSso() + { + return this.forceKeycloakSso.getValue(); + } + + /** + * @param bodyBufferLimit + * the bodyBufferLimit to set + */ + public void setBodyBufferLimit(final Integer bodyBufferLimit) + { + this.bodyBufferLimit.setValue(bodyBufferLimit); + } + + /** + * @return the bodyBufferLimit + */ + public Integer getBodyBufferLimit() + { + return this.bodyBufferLimit.getValue(); + } + + /** + * @param sslRedirectPort + * the sslRedirectPort to set + */ + public void setSslRedirectPort(final Integer sslRedirectPort) + { + this.sslRedirectPort.setValue(sslRedirectPort); + } + + /** + * @return the sslRedirectPort + */ + public Integer getSslRedirectPort() + { + return this.sslRedirectPort.getValue(); + } + + /** + * @param sessionMapperLimit + * the sessionMapperLimit to set + */ + public void setSessionMapperLimit(final Integer sessionMapperLimit) + { + this.sessionMapperLimit.setValue(sessionMapperLimit); + } + + /** + * @return the sessionMapperLimit + */ + public Integer getSessionMapperLimit() + { + return this.sessionMapperLimit.getValue(); + } + + /** + * + * {@inheritDoc} + */ + @Override + public ConfigElement combine(final ConfigElement configElement) + { + if (!(configElement instanceof KeycloakAuthenticationConfigElement)) + { + throw new IllegalArgumentException("Cannot combine with " + configElement); + } + + final KeycloakAuthenticationConfigElement combined = new KeycloakAuthenticationConfigElement(); + final KeycloakAuthenticationConfigElement otherConfigElement = (KeycloakAuthenticationConfigElement) configElement; + + if (otherConfigElement.enhanceLoginForm.isUnset()) + { + combined.enhanceLoginForm.unset(); + } + else + { + combined.setEnhanceLoginForm(otherConfigElement.getEnhanceLoginForm() != null ? otherConfigElement.getEnhanceLoginForm() + : this.getEnhanceLoginForm()); + } + + if (otherConfigElement.enhanceLoginForm.isUnset()) + { + combined.enhanceLoginForm.unset(); + } + else + { + combined.setEnableSsoFilter( + otherConfigElement.getEnableSsoFilter() != null ? otherConfigElement.getEnableSsoFilter() : this.getEnableSsoFilter()); + } + + if (otherConfigElement.forceKeycloakSso.isUnset()) + { + combined.forceKeycloakSso.unset(); + } + else + { + combined.setForceKeycloakSso(otherConfigElement.getForceKeycloakSso() != null ? otherConfigElement.getForceKeycloakSso() + : this.getForceKeycloakSso()); + } + + if (otherConfigElement.bodyBufferLimit.isUnset()) + { + combined.bodyBufferLimit.unset(); + } + else + { + combined.setBodyBufferLimit( + otherConfigElement.getBodyBufferLimit() != null ? otherConfigElement.getBodyBufferLimit() : this.getBodyBufferLimit()); + } + + if (otherConfigElement.sslRedirectPort.isUnset()) + { + combined.sslRedirectPort.unset(); + } + else + { + combined.setSslRedirectPort( + otherConfigElement.getSslRedirectPort() != null ? otherConfigElement.getSslRedirectPort() : this.getSslRedirectPort()); + } + + if (otherConfigElement.sessionMapperLimit.isUnset()) + { + combined.sessionMapperLimit.unset(); + } + else + { + combined.setSessionMapperLimit(otherConfigElement.getSessionMapperLimit() != null ? otherConfigElement.getSessionMapperLimit() + : this.getSessionMapperLimit()); + } + + return combined; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() + { + final StringBuilder builder = new StringBuilder(); + builder.append("KeycloakAuthenticationConfigElement ["); + builder.append("enhanceLoginForm="); + builder.append(this.enhanceLoginForm); + builder.append(", "); + builder.append("enableSsoFilter="); + builder.append(this.enableSsoFilter); + builder.append(", "); + builder.append("forceKeycloakSso="); + builder.append(this.forceKeycloakSso); + builder.append(", "); + builder.append("bodyBufferLimit="); + builder.append(this.bodyBufferLimit); + builder.append(", "); + builder.append("sslRedirectPort="); + builder.append(this.sslRedirectPort); + builder.append(", "); + builder.append("sessionMapperLimit="); + builder.append(this.sessionMapperLimit); + builder.append("]"); + return builder.toString(); + } + +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAuthenticationConfigElementReader.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAuthenticationConfigElementReader.java new file mode 100644 index 0000000..46f4944 --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/config/KeycloakAuthenticationConfigElementReader.java @@ -0,0 +1,87 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.config; + +import org.dom4j.Element; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.extensions.config.ConfigElement; +import org.springframework.extensions.config.xml.elementreader.ConfigElementReader; + +/** + * @author Axel Faust + */ +public class KeycloakAuthenticationConfigElementReader implements ConfigElementReader +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationConfigElementReader.class); + + /** + * {@inheritDoc} + */ + @Override + public ConfigElement parse(final Element element) + { + final KeycloakAuthenticationConfigElement configElement = new KeycloakAuthenticationConfigElement(); + + final Element enhanceLoginForm = element.element("enhance-login-form"); + if (enhanceLoginForm != null) + { + final String value = enhanceLoginForm.getTextTrim(); + configElement.setEnhanceLoginForm(value.isEmpty() ? null : Boolean.valueOf(value)); + } + + final Element enableSsoFilter = element.element("enable-sso-filter"); + if (enableSsoFilter != null) + { + final String value = enableSsoFilter.getTextTrim(); + configElement.setEnableSsoFilter(value.isEmpty() ? null : Boolean.valueOf(value)); + } + + final Element forceKeycloakSso = element.element("force-keycloak-sso"); + if (forceKeycloakSso != null) + { + final String value = forceKeycloakSso.getTextTrim(); + configElement.setForceKeycloakSso(value.isEmpty() ? null : Boolean.valueOf(value)); + } + + final Element bodyBufferLimit = element.element("body-buffer-limit"); + if (bodyBufferLimit != null) + { + final String value = bodyBufferLimit.getTextTrim(); + configElement.setBodyBufferLimit(value.isEmpty() ? null : Integer.valueOf(value)); + } + + final Element sslRedirectPort = element.element("ssl-redirect-port"); + if (sslRedirectPort != null) + { + final String value = sslRedirectPort.getTextTrim(); + configElement.setSslRedirectPort(value.isEmpty() ? null : Integer.valueOf(value)); + } + + final Element sessionMapperLimit = element.element("session-mapper-limit"); + if (sessionMapperLimit != null) + { + final String value = sessionMapperLimit.getTextTrim(); + configElement.setSessionMapperLimit(value.isEmpty() ? null : Integer.valueOf(value)); + } + + LOGGER.debug("Read configuration element {} from XML section", configElement); + + return configElement; + } + +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/remote/BearerTokenAwareSlingshotAlfrescoConnector.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/remote/BearerTokenAwareSlingshotAlfrescoConnector.java new file mode 100644 index 0000000..386b524 --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/remote/BearerTokenAwareSlingshotAlfrescoConnector.java @@ -0,0 +1,67 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.remote; + +import java.util.Collections; + +import org.alfresco.web.site.servlet.SlingshotAlfrescoConnector; +import org.springframework.extensions.config.RemoteConfigElement.ConnectorDescriptor; +import org.springframework.extensions.webscripts.connector.ConnectorContext; +import org.springframework.extensions.webscripts.connector.ConnectorSession; +import org.springframework.extensions.webscripts.connector.RemoteClient; + +/** + * @author Axel Faust + */ +public class BearerTokenAwareSlingshotAlfrescoConnector extends SlingshotAlfrescoConnector +{ + + public static final String CS_PARAM_BEARER_TOKEN = "bearerToken"; + + /** + * Constructs a new instance of this class. + * + * @param descriptor + * the descriptor / configuration of this connector + * @param endpoint + * the endpoint with which this connector instance should connect + */ + public BearerTokenAwareSlingshotAlfrescoConnector(final ConnectorDescriptor descriptor, final String endpoint) + { + super(descriptor, endpoint); + } + + /** + * + * {@inheritDoc} + */ + @Override + protected void applyRequestHeaders(final RemoteClient remoteClient, final ConnectorContext context) + { + // apply default mapping of headers + super.applyRequestHeaders(remoteClient, context); + + final ConnectorSession connectorSession = this.getConnectorSession(); + if (connectorSession != null) + { + final String bearerToken = connectorSession.getParameter(CS_PARAM_BEARER_TOKEN); + if (bearerToken != null && !bearerToken.trim().isEmpty()) + { + remoteClient.setRequestProperties(Collections.singletonMap("Authorization", "Bearer " + bearerToken)); + } + } + } +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/spring/KeycloakAuthenticationFilterActivation.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/spring/KeycloakAuthenticationFilterActivation.java new file mode 100644 index 0000000..70e5820 --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/spring/KeycloakAuthenticationFilterActivation.java @@ -0,0 +1,102 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.spring; + +import org.alfresco.util.PropertyCheck; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.config.BeanDefinition; +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.BeanDefinitionRegistryPostProcessor; +import org.springframework.beans.factory.support.GenericBeanDefinition; + +import de.acosix.alfresco.keycloak.share.web.KeycloakAuthenticationFilter; + +/** + * @author Axel Faust + */ +public class KeycloakAuthenticationFilterActivation implements BeanDefinitionRegistryPostProcessor, InitializingBean +{ + + private static final String DEFAULT_SSO_AUTHENTICATION_FILTER_NAME = "SSOAuthenticationFilter"; + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationFilterActivation.class); + + protected String moduleId; + + /** + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "moduleId", this.moduleId); + } + + /** + * @param moduleId + * the moduleId to set + */ + public void setModuleId(final String moduleId) + { + this.moduleId = moduleId; + } + + /** + * {@inheritDoc} + */ + @Override + public void postProcessBeanFactory(final ConfigurableListableBeanFactory beanFactory) throws BeansException + { + // NO-OP + } + + /** + * {@inheritDoc} + */ + @Override + public void postProcessBeanDefinitionRegistry(final BeanDefinitionRegistry registry) throws BeansException + { + final String keycloakFilterBeanName = this.moduleId + "." + KeycloakAuthenticationFilter.class.getSimpleName(); + + if (registry.containsBeanDefinition(keycloakFilterBeanName)) + { + // re-register default filter under different name + final BeanDefinition defaultSsoAuthenticationFilter = registry.getBeanDefinition(DEFAULT_SSO_AUTHENTICATION_FILTER_NAME); + registry.removeBeanDefinition(DEFAULT_SSO_AUTHENTICATION_FILTER_NAME); + final String defaultSsoAuthenticationFilterReplacementName = this.moduleId + ".default" + + DEFAULT_SSO_AUTHENTICATION_FILTER_NAME; + registry.registerBeanDefinition(defaultSsoAuthenticationFilterReplacementName, defaultSsoAuthenticationFilter); + + // re-register our filter under default name + final BeanDefinition keycloakSsoAuthenticationFilter = registry.getBeanDefinition(keycloakFilterBeanName); + registry.removeBeanDefinition(keycloakFilterBeanName); + ((GenericBeanDefinition) keycloakSsoAuthenticationFilter).setAbstract(false); + keycloakSsoAuthenticationFilter.getPropertyValues().add("defaultSsoFilter", + new RuntimeBeanReference(defaultSsoAuthenticationFilterReplacementName)); + registry.registerBeanDefinition(DEFAULT_SSO_AUTHENTICATION_FILTER_NAME, keycloakSsoAuthenticationFilter); + } + else + { + LOGGER.error("Cannot activate KeycloakAuthenticationFilter bean as abstract bean {} was not found in Spring context", + keycloakFilterBeanName); + } + } +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/DefaultSessionIdMapper.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/DefaultSessionIdMapper.java new file mode 100644 index 0000000..21b8886 --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/DefaultSessionIdMapper.java @@ -0,0 +1,316 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.web; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import org.keycloak.adapters.spi.InMemorySessionIdMapper; +import org.keycloak.adapters.spi.SessionIdMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.extensions.config.ConfigService; + +import de.acosix.alfresco.keycloak.share.config.KeycloakAuthenticationConfigElement; + +/** + * This implementation of a {@link SessionIdMapper Keycloak session ID mapper} is based on the {@link InMemorySessionIdMapper in-memory + * mapper}, but uses a better model for synchronization and respects configured size limits, ejecting least-recently active sessions first. + * Activity of session with regards to being determined the "least-recently active" session is based upon validation calls to + * {@link #hasSession(String) hasSession}. + * + * @author Axel Faust + */ +public class DefaultSessionIdMapper implements SessionIdMapper, InitializingBean +{ + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSessionIdMapper.class); + + private static final int DEFAULT_SESSION_COUNT_LIMIT = 1000; + + protected final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); + + protected final Map ssoToSession = new HashMap<>(); + + protected final Map sessionToSso = new HashMap<>(); + + protected final Map> principalToSession = new HashMap<>(); + + protected final Map sessionToPrincipal = new HashMap<>(); + + protected ConfigService configService; + + protected int sessionCountLimit = DEFAULT_SESSION_COUNT_LIMIT; + + protected Set sessionUsedOrder; + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + if (this.configService != null) + { + final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService + .getConfig("Keycloak").getConfigElement(KeycloakAuthenticationConfigElement.NAME); + final Integer sessionMapperLimit = keycloakAuthConfig.getSessionMapperLimit(); + if (sessionMapperLimit != null) + { + this.sessionCountLimit = sessionMapperLimit.intValue(); + } + } + + if (this.sessionCountLimit <= 0) + { + LOGGER.warn( + "Session count limit is set to {} - session ID mapper will not restrict size of internal data structures (this can cause OOMEs)", + this.sessionCountLimit); + } + else + { + this.sessionUsedOrder = new LinkedHashSet<>(); + } + } + + /** + * @param configService + * the configService to set + */ + public void setConfigService(final ConfigService configService) + { + this.configService = configService; + } + + /** + * @param sessionCountLimit + * the sessionCountLimit to set + */ + public void setSessionCountLimit(final int sessionCountLimit) + { + this.sessionCountLimit = sessionCountLimit; + } + + @Override + public boolean hasSession(final String id) + { + this.lock.readLock().lock(); + try + { + LOGGER.debug("Checking hasSession for {}", id); + final boolean hasSession = this.sessionToSso.containsKey(id) || this.sessionToPrincipal.containsKey(id); + LOGGER.debug("Session {}", hasSession ? "is mapped" : "is not mapped"); + + if (hasSession && this.sessionCountLimit > 0) + { + synchronized (this.sessionUsedOrder) + { + this.sessionUsedOrder.remove(id); + this.sessionUsedOrder.add(id); + } + } + return hasSession; + } + finally + { + this.lock.readLock().unlock(); + } + } + + /** + * + * {@inheritDoc} + */ + @Override + public void clear() + { + this.lock.writeLock().lock(); + try + { + LOGGER.info("Clearing all mappings"); + this.ssoToSession.clear(); + this.sessionToSso.clear(); + this.principalToSession.clear(); + this.sessionToPrincipal.clear(); + } + finally + { + this.lock.writeLock().unlock(); + } + } + + /** + * + * {@inheritDoc} + */ + @Override + public Set getUserSessions(final String principal) + { + Set userSessions; + this.lock.readLock().lock(); + try + { + LOGGER.debug("Retrieving user sessions for {}", principal); + final Set lookup = this.principalToSession.get(principal); + if (lookup != null) + { + userSessions = new HashSet<>(); + userSessions.addAll(lookup); + } + else + { + userSessions = Collections.emptySet(); + } + } + finally + { + this.lock.readLock().unlock(); + } + LOGGER.debug("Principal {} is mapped to sessions {}", principal, userSessions); + return userSessions; + } + + /** + * + * {@inheritDoc} + */ + @Override + public String getSessionFromSSO(final String sso) + { + this.lock.readLock().lock(); + try + { + return this.ssoToSession.get(sso); + } + finally + { + this.lock.readLock().unlock(); + } + } + + /** + * + * {@inheritDoc} + */ + @Override + public void map(final String sso, final String principal, final String session) + { + this.lock.writeLock().lock(); + try + { + LOGGER.debug("Adding mapping ({}, {}, {})", sso, principal, session); + + if (sso != null) + { + this.ssoToSession.put(sso, session); + this.sessionToSso.put(session, sso); + + } + + if (principal != null) + { + this.principalToSession.compute(principal, (key, value) -> { + if (value == null) + { + value = new HashSet<>(); + } + value.add(session); + return value; + }); + this.sessionToPrincipal.put(session, principal); + } + + if (this.sessionCountLimit > 0 && sso != null && principal != null) + { + synchronized (this.sessionUsedOrder) + { + this.sessionUsedOrder.add(session); + + final int sessionsToRemove = this.sessionUsedOrder.size() - this.sessionCountLimit; + if (sessionsToRemove == 1) + { + final String sessionToRemove = this.sessionUsedOrder.iterator().next(); + this.removeSession(sessionToRemove); + } + // should really not happen, but in place should we ever switch to a more bulk-handling + else if (sessionsToRemove > 0) + { + final List sessionsForRemoval = new ArrayList<>(this.sessionUsedOrder).subList(0, sessionsToRemove); + sessionsForRemoval.forEach(this::removeSession); + } + } + } + } + finally + { + this.lock.writeLock().unlock(); + } + } + + /** + * + * {@inheritDoc} + */ + @Override + public void removeSession(final String session) + { + this.lock.writeLock().lock(); + try + { + LOGGER.debug("Removing session {}", session); + + final String sso = this.sessionToSso.remove(session); + if (sso != null) + { + this.ssoToSession.remove(sso); + } + + final String principal = this.sessionToPrincipal.remove(session); + if (principal != null) + { + this.principalToSession.computeIfPresent(principal, (key, value) -> { + value.remove(session); + if (value.isEmpty()) + { + value = null; + } + return value; + }); + } + + if (this.sessionCountLimit > 0) + { + synchronized (this.sessionUsedOrder) + { + this.sessionUsedOrder.remove(session); + } + } + } + finally + { + this.lock.writeLock().unlock(); + } + } +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java new file mode 100644 index 0000000..1b3f582 --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/KeycloakAuthenticationFilter.java @@ -0,0 +1,1126 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.web; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.servlet.FilterChain; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + +import org.alfresco.util.PropertyCheck; +import org.alfresco.web.site.servlet.SSOAuthenticationFilter; +import org.keycloak.KeycloakSecurityContext; +import org.keycloak.adapters.AdapterDeploymentContext; +import org.keycloak.adapters.AuthenticatedActionsHandler; +import org.keycloak.adapters.HttpClientBuilder; +import org.keycloak.adapters.KeycloakDeployment; +import org.keycloak.adapters.KeycloakDeploymentBuilder; +import org.keycloak.adapters.OAuthRequestAuthenticator; +import org.keycloak.adapters.OidcKeycloakAccount; +import org.keycloak.adapters.PreAuthActionsHandler; +import org.keycloak.adapters.servlet.FilterRequestAuthenticator; +import org.keycloak.adapters.servlet.OIDCFilterSessionStore; +import org.keycloak.adapters.servlet.OIDCServletHttpFacade; +import org.keycloak.adapters.spi.AuthOutcome; +import org.keycloak.adapters.spi.AuthenticationError; +import org.keycloak.adapters.spi.KeycloakAccount; +import org.keycloak.adapters.spi.SessionIdMapper; +import org.keycloak.adapters.spi.UserSessionManagement; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.extensions.config.ConfigService; +import org.springframework.extensions.config.RemoteConfigElement; +import org.springframework.extensions.config.RemoteConfigElement.EndpointDescriptor; +import org.springframework.extensions.surf.RequestContext; +import org.springframework.extensions.surf.RequestContextUtil; +import org.springframework.extensions.surf.ServletUtil; +import org.springframework.extensions.surf.UserFactory; +import org.springframework.extensions.surf.exception.ConnectorServiceException; +import org.springframework.extensions.surf.mvc.PageViewResolver; +import org.springframework.extensions.surf.site.AuthenticationUtil; +import org.springframework.extensions.surf.types.Page; +import org.springframework.extensions.surf.types.PageType; +import org.springframework.extensions.webscripts.Description.RequiredAuthentication; +import org.springframework.extensions.webscripts.Status; +import org.springframework.extensions.webscripts.connector.Connector; +import org.springframework.extensions.webscripts.connector.ConnectorService; +import org.springframework.extensions.webscripts.servlet.DependencyInjectedFilter; + +import de.acosix.alfresco.keycloak.share.config.KeycloakAdapterConfigElement; +import de.acosix.alfresco.keycloak.share.config.KeycloakAuthenticationConfigElement; +import de.acosix.alfresco.keycloak.share.remote.BearerTokenAwareSlingshotAlfrescoConnector; + +/** + * Keycloak-based authentication filter class which can act as a standalone filter or a facade to the default {@link SSOAuthenticationFilter + * SSO filter}. + * + * @author Axel Faust + */ +public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, InitializingBean, ApplicationContextAware +{ + + private static final String HEADER_AUTHORIZATION = "Authorization"; + + private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationFilter.class); + + private static final String PROXY_URL_PATTERN = "^(?:/page)?/proxy/([^/]+)(-noauth)?/.+$"; + + private static final String KEYCLOAK_ACTION_URL_PATTERN = "^(?:/page)?/keycloak/k_[^/]+$"; + + private static final Pattern PROXY_URL_PATTERN_COMPILED = Pattern.compile(PROXY_URL_PATTERN); + + private static final String PAGE_SERVLET_PATH = "/page"; + + private static final String LOGIN_PAGE_TYPE_PARAMETER_VALUE = "login"; + + private static final String PAGE_TYPE_PARAMETER_NAME = "pt"; + + private static final String LOGIN_PATH_INFORMATION = "/dologin"; + + private static final String LOGOUT_PATH_INFORMATION = "/dologout"; + + private static final int DEFAULT_BODY_BUFFER_LIMIT = 32 * 1024;// 32 KiB + + private static final ThreadLocal LOGIN_REDIRECT_URL = new ThreadLocal<>(); + + protected ApplicationContext applicationContext; + + protected DependencyInjectedFilter defaultSsoFilter; + + protected ConfigService configService; + + protected ConnectorService connectorService; + + protected PageViewResolver pageViewResolver; + + protected SessionIdMapper sessionIdMapper; + + protected String primaryEndpoint; + + protected List secondaryEndpoints; + + protected boolean externalAuthEnabled = false; + + protected boolean filterEnabled = false; + + protected boolean loginFormEnhancementEnabled = false; + + protected boolean forceSso = false; + + protected KeycloakDeployment keycloakDeployment; + + protected AdapterDeploymentContext deploymentContext; + + /** + * Retrieves the Keycloak login redirect URI set in the current thread's scope for use in any lazy redirect handling, e.g. as an action + * in the login form. + * + * @return the login redirect URL, or {@code null} if no URL was set in the current thread's scope. + */ + public static String getLoginRedirectUrl() + { + return LOGIN_REDIRECT_URL.get(); + } + + /** + * Utility method to check if the current user has been authenticated by this filter / via Keycloak. + * + * @return {@code true} if the currently logged in user was authenticated by Keycloak, {@code false} otherwise + */ + public static boolean isAuthenticatedByKeycloak() + { + final HttpServletRequest req = ServletUtil.getRequest(); + boolean authenticatedByKeycloak = false; + + if (req != null) + { + final HttpSession currentSession = req.getSession(false); + authenticatedByKeycloak = currentSession != null && AuthenticationUtil.isAuthenticated(req) + && currentSession.getAttribute(KeycloakAccount.class.getName()) != null; + } + return authenticatedByKeycloak; + } + + /** + * {@inheritDoc} + */ + @Override + public void setApplicationContext(final ApplicationContext applicationContext) + { + this.applicationContext = applicationContext; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "primaryEndpoint", this.primaryEndpoint); + PropertyCheck.mandatory(this, "configService", this.configService); + PropertyCheck.mandatory(this, "connectorService", this.connectorService); + PropertyCheck.mandatory(this, "pageViewResolver", this.pageViewResolver); + PropertyCheck.mandatory(this, "sessionIdMapper", this.sessionIdMapper); + + LOGGER.info("Setting up filter for primary endpoint {} and secondary endpoints {}", this.primaryEndpoint, this.secondaryEndpoints); + + final RemoteConfigElement remoteConfig = (RemoteConfigElement) this.configService.getConfig("Remote").getConfigElement("remote"); + if (remoteConfig != null) + { + final EndpointDescriptor endpoint = remoteConfig.getEndpointDescriptor(this.primaryEndpoint); + if (endpoint != null) + { + this.externalAuthEnabled = endpoint.getExternalAuth(); + } + else + { + LOGGER.error("Endpoint {} has not been defined in the application configuration", this.primaryEndpoint); + } + + if (this.secondaryEndpoints != null) + { + this.secondaryEndpoints = this.secondaryEndpoints.stream().filter(secondaryEndpoint -> { + final boolean endpointExists = remoteConfig.getEndpointDescriptor(secondaryEndpoint) != null; + if (!endpointExists) + { + LOGGER.info("Excluding configured secondary endpoint {} which is not defined in the application configuration", + secondaryEndpoint); + } + return endpointExists; + }).collect(Collectors.toList()); + } + } + else + { + LOGGER.error("No remote configuration has been defined for the application"); + } + + final KeycloakAdapterConfigElement keycloakAdapterConfig = (KeycloakAdapterConfigElement) this.configService.getConfig("Keycloak") + .getConfigElement(KeycloakAdapterConfigElement.NAME); + if (keycloakAdapterConfig != null) + { + final AdapterConfig adapterConfiguration = keycloakAdapterConfig.buildAdapterConfiguration(); + + // disable any CORS handling (if CORS is relevant, it should be handled by Share / Surf) + adapterConfiguration.setCors(false); + // BASIC authentication should never be used + adapterConfiguration.setEnableBasicAuth(false); + + this.keycloakDeployment = KeycloakDeploymentBuilder.build(adapterConfiguration); + + // even in newer version than used by ACS 6.x does Keycloak lib not allow timeout configuration + if (this.keycloakDeployment.getClient() != null) + { + final Long connectionTimeout = keycloakAdapterConfig.getConnectionTimeout(); + final Long socketTimeout = keycloakAdapterConfig.getSocketTimeout(); + + HttpClientBuilder httpClientBuilder = new HttpClientBuilder(); + if (connectionTimeout != null && connectionTimeout.longValue() >= 0) + { + httpClientBuilder = httpClientBuilder.establishConnectionTimeout(connectionTimeout.longValue(), TimeUnit.MILLISECONDS); + } + if (socketTimeout != null && socketTimeout.longValue() >= 0) + { + httpClientBuilder = httpClientBuilder.socketTimeout(socketTimeout.longValue(), TimeUnit.MILLISECONDS); + } + this.keycloakDeployment.setClient(httpClientBuilder.build(adapterConfiguration)); + } + + this.deploymentContext = new AdapterDeploymentContext(this.keycloakDeployment); + } + else + { + LOGGER.error("No Keycloak adapter configuration has been defined for the application"); + } + + final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService + .getConfig("Keycloak").getConfigElement(KeycloakAuthenticationConfigElement.NAME); + if (keycloakAuthConfig != null) + { + this.filterEnabled = Boolean.TRUE.equals(keycloakAuthConfig.getEnableSsoFilter()); + this.loginFormEnhancementEnabled = Boolean.TRUE.equals(keycloakAuthConfig.getEnhanceLoginForm()); + this.forceSso = Boolean.TRUE.equals(keycloakAuthConfig.getForceKeycloakSso()); + } + else + { + LOGGER.error("No Keycloak authentication configuration has been defined for the application"); + } + + if (this.filterEnabled && !this.keycloakDeployment.isConfigured()) + { + throw new IllegalStateException("The Keycloak adapter has not been properly configured"); + } + } + + /** + * @param defaultSsoFilter + * the defaultSsoFilter to set + */ + public void setDefaultSsoFilter(final DependencyInjectedFilter defaultSsoFilter) + { + this.defaultSsoFilter = defaultSsoFilter; + } + + /** + * @param configService + * the configService to set + */ + public void setConfigService(final ConfigService configService) + { + this.configService = configService; + } + + /** + * @param connectorService + * the connectorService to set + */ + public void setConnectorService(final ConnectorService connectorService) + { + this.connectorService = connectorService; + } + + /** + * @param pageViewResolver + * the pageViewResolver to set + */ + public void setPageViewResolver(final PageViewResolver pageViewResolver) + { + this.pageViewResolver = pageViewResolver; + } + + /** + * @param sessionIdMapper + * the sessionIdMapper to set + */ + public void setSessionIdMapper(final SessionIdMapper sessionIdMapper) + { + this.sessionIdMapper = sessionIdMapper; + } + + /** + * @param primaryEndpoint + * the primaryEndpoint to set + */ + public void setPrimaryEndpoint(final String primaryEndpoint) + { + this.primaryEndpoint = primaryEndpoint; + } + + /** + * @param secondaryEndpoints + * the secondaryEndpoints to set + */ + public void setSecondaryEndpoints(final List secondaryEndpoints) + { + this.secondaryEndpoints = secondaryEndpoints != null ? new ArrayList<>(secondaryEndpoints) : null; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void doFilter(final ServletContext context, final ServletRequest request, final ServletResponse response, + final FilterChain chain) throws IOException, ServletException + { + try + { + final HttpServletRequest req = (HttpServletRequest) request; + final HttpServletResponse res = (HttpServletResponse) response; + LOGGER.debug("Entered doFilter for {}", req); + + if (this.isLogoutRequest(req)) + { + this.processLogout(context, req, res, chain); + } + else + { + final boolean skip = this.checkForSkipCondition(req, res); + + if (skip) + { + if (!AuthenticationUtil.isAuthenticated(req) && this.loginFormEnhancementEnabled && this.isLoginPage(req)) + { + this.prepareLoginFormEnhancement(context, req, res); + } + + this.continueFilterChain(context, request, response, chain); + } + else + { + this.processKeycloakAuthenticationAndActions(context, req, res, chain); + } + } + } + finally + { + LOGIN_REDIRECT_URL.remove(); + } + } + + protected void processLogout(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res, + final FilterChain chain) throws IOException, ServletException + { + final HttpSession currentSession = req.getSession(false); + + if (currentSession != null && AuthenticationUtil.isAuthenticated(req) + && currentSession.getAttribute(KeycloakAccount.class.getName()) != null + && this.sessionIdMapper.hasSession(currentSession.getId())) + { + LOGGER.debug("Processing logout for Keycloak-authenticated user {} in session {}", AuthenticationUtil.getUserId(req), + currentSession.getId()); + + final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService + .getConfig("Keycloak").getConfigElement(KeycloakAuthenticationConfigElement.NAME); + + final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res); + final Integer bodyBufferLimit = keycloakAuthConfig.getBodyBufferLimit(); + final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade, + bodyBufferLimit != null ? bodyBufferLimit.intValue() : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null); + + tokenStore.logout(); + + chain.doFilter(req, res); + } + else + { + this.continueFilterChain(context, req, res, chain); + } + } + + /** + * Processes Keycloak authentication and potential action operations. If a Keycloak action has been processed, the request processing + * will be terminated. Otherwise processing may continue with the filter chain (if still applicable). + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * @param chain + * the filter chain + * @throws IOException + * if any error occurs during Keycloak authentication or processing of the filter chain + * @throws ServletException + * if any error occurs during Keycloak authentication or processing of the filter chain + */ + protected void processKeycloakAuthenticationAndActions(final ServletContext context, final HttpServletRequest req, + final HttpServletResponse res, final FilterChain chain) throws IOException, ServletException + { + LOGGER.debug("Processing Keycloak authentication on request to {}", req.getRequestURL()); + + final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService + .getConfig("Keycloak").getConfigElement(KeycloakAuthenticationConfigElement.NAME); + + final Integer bodyBufferLimit = keycloakAuthConfig.getBodyBufferLimit(); + final Integer sslRedirectPort = keycloakAuthConfig.getSslRedirectPort(); + + final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res); + + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : ""); + if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN)) + { + LOGGER.debug("Applying Keycloak pre-auth actions handler"); + final PreAuthActionsHandler preActions = new PreAuthActionsHandler(new UserSessionManagement() + { + + /** + * + * {@inheritDoc} + */ + @Override + public void logoutAll() + { + KeycloakAuthenticationFilter.this.sessionIdMapper.clear(); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void logoutHttpSessions(final List ids) + { + ids.forEach(KeycloakAuthenticationFilter.this.sessionIdMapper::removeSession); + } + }, this.deploymentContext, facade); + + if (preActions.handleRequest()) + { + LOGGER.debug("Keycloak pre-auth actions processed the request - stopping filter chain execution"); + return; + } + } + + final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade, + bodyBufferLimit != null ? bodyBufferLimit.intValue() : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, + this.sessionIdMapper); + + // use 8443 as default SSL redirect based on Tomcat default server.xml configuration + final FilterRequestAuthenticator authenticator = new FilterRequestAuthenticator(this.keycloakDeployment, tokenStore, facade, req, + sslRedirectPort != null ? sslRedirectPort.intValue() : 8443); + final AuthOutcome authOutcome = authenticator.authenticate(); + + if (authOutcome == AuthOutcome.AUTHENTICATED) + { + this.onKeycloakAuthenticationSuccess(context, req, res, chain, facade, tokenStore); + } + else if (authOutcome == AuthOutcome.NOT_ATTEMPTED && this.forceSso) + { + LOGGER.debug("No authentication took place - sending authentication challenge"); + authenticator.getChallenge().challenge(facade); + } + else if (authOutcome == AuthOutcome.FAILED) + { + this.onKeycloakAuthenticationFailure(context, req, res, chain); + } + else + { + + if (authOutcome == AuthOutcome.NOT_ATTEMPTED) + { + LOGGER.debug("No authentication took place - continueing with filter chain processing"); + + if (this.loginFormEnhancementEnabled) + { + this.prepareLoginFormEnhancement(context, req, res, authenticator); + } + } + else + { + LOGGER.warn("Unexpected authentication outcome {} - continueing with filter chain processing", authOutcome); + } + + this.continueFilterChain(context, req, res, chain); + } + } + + /** + * Sets up the necessary state to enhance the login form customisation to provide an action to perform a Keycloak login via a redirect. + * + * @param context + * the servlet context + * @param req + * the HTTP servlet request being processed + * @param res + * the HTTP servlet response being processed + * @param authenticator + * the authenticator holding the challenge for a login redirect + */ + protected void prepareLoginFormEnhancement(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res, + final FilterRequestAuthenticator authenticator) + { + final RedirectCaptureServletHttpFacade captureFacade = new RedirectCaptureServletHttpFacade(req); + + authenticator.getChallenge().challenge(captureFacade); + + // reset existing cookies + this.resetStateCookies(context, req, res); + + captureFacade.getCookies().stream().map(cookie -> { + cookie.setPath(context.getContextPath()); + return cookie; + }).forEach(res::addCookie); + + final List redirects = captureFacade.getHeaders().get("Location"); + if (redirects != null && !redirects.isEmpty()) + { + LOGIN_REDIRECT_URL.set(redirects.get(0)); + } + } + + /** + * Sets up the necessary state to enhance the login form customisation to provide an action to perform a Keycloak login via a redirect. + * + * @param context + * the servlet context + * @param req + * the HTTP servlet request being processed + * @param res + * the HTTP servlet response being processed + */ + protected void prepareLoginFormEnhancement(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res) + { + final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService + .getConfig("Keycloak").getConfigElement(KeycloakAuthenticationConfigElement.NAME); + + final Integer bodyBufferLimit = keycloakAuthConfig.getBodyBufferLimit(); + final Integer sslRedirectPort = keycloakAuthConfig.getSslRedirectPort(); + + // fake a request that will yield a redirect + final HttpServletRequest wrappedReq = new HttpServletRequestWrapper(req) + { + + /** + * {@inheritDoc} + */ + @Override + public String getQueryString() + { + // no query parameters, so no code= and no error= + // this will cause login redirect challenge to be generated + return ""; + } + + }; + + final RedirectCaptureServletHttpFacade captureFacade = new RedirectCaptureServletHttpFacade(wrappedReq); + + final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, captureFacade, + bodyBufferLimit != null ? bodyBufferLimit.intValue() : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null); + + // use 8443 as default SSL redirect based on Tomcat default server.xml configuration + final OAuthRequestAuthenticator authenticator = new OAuthRequestAuthenticator(null, captureFacade, this.keycloakDeployment, + sslRedirectPort != null ? sslRedirectPort.intValue() : 8443, tokenStore); + + final AuthOutcome authOutcome = authenticator.authenticate(); + if (authOutcome != AuthOutcome.NOT_ATTEMPTED) + { + LOGGER.error("OAuthRequestAuthenticator yielded unexpected auth outcome {}", authOutcome); + res.setStatus(Status.STATUS_INTERNAL_SERVER_ERROR); + throw new IllegalStateException("OAuthRequestAuthenticator did not generate login redirect"); + } + authenticator.getChallenge().challenge(captureFacade); + + // reset existing cookies + this.resetStateCookies(context, req, res); + + captureFacade.getCookies().stream().map(cookie -> { + // always scope to context path - otherwise we end up getting multiple cookies for multiple paths + cookie.setPath(context.getContextPath()); + return cookie; + }).forEach(res::addCookie); + + final List redirects = captureFacade.getHeaders().get("Location"); + if (redirects != null && !redirects.isEmpty()) + { + LOGIN_REDIRECT_URL.set(redirects.get(0)); + } + } + + /** + * Processes a sucessfull authentication via Keycloak. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * @param chain + * the filter chain + * @param facade + * the Keycloak HTTP facade + * @param tokenStore + * the Keycloak token store + * @throws IOException + * if any error occurs during Keycloak authentication or processing of the filter chain + * @throws ServletException + * if any error occurs during Keycloak authentication or processing of the filter chain + */ + protected void onKeycloakAuthenticationSuccess(final ServletContext context, final HttpServletRequest req, + final HttpServletResponse res, final FilterChain chain, final OIDCServletHttpFacade facade, + final OIDCFilterSessionStore tokenStore) throws IOException, ServletException + { + final HttpSession session = req.getSession(); + final Object keycloakAccount = session != null ? session.getAttribute(KeycloakAccount.class.getName()) : null; + if (keycloakAccount instanceof OidcKeycloakAccount) + { + final KeycloakSecurityContext keycloakSecurityContext = ((OidcKeycloakAccount) keycloakAccount).getKeycloakSecurityContext(); + final AccessToken accessToken = keycloakSecurityContext.getToken(); + final String userId = accessToken.getPreferredUsername(); + LOGGER.debug("User {} successfully authenticated via Keycloak", userId); + + final String accessTokenString = keycloakSecurityContext.getTokenString(); + this.updateEndpointConnectorBearerToken(this.primaryEndpoint, userId, session, accessTokenString); + if (this.secondaryEndpoints != null) + { + this.secondaryEndpoints.forEach(endpoint -> { + this.updateEndpointConnectorBearerToken(endpoint, userId, session, accessTokenString); + }); + } + + session.setAttribute(UserFactory.SESSION_ATTRIBUTE_EXTERNAL_AUTH, Boolean.TRUE); + session.setAttribute(UserFactory.SESSION_ATTRIBUTE_KEY_USER_ID, userId); + } + + if (facade.isEnded()) + { + LOGGER.debug("Authenticator already handled response"); + return; + } + + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : ""); + if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN)) + { + LOGGER.debug("Applying Keycloak authenticated actions handler"); + final AuthenticatedActionsHandler actions = new AuthenticatedActionsHandler(this.keycloakDeployment, facade); + if (actions.handledRequest()) + { + LOGGER.debug("Keycloak authenticated actions processed the request - stopping filter chain execution"); + return; + } + } + + LOGGER.debug("Continueing with filter chain processing"); + final HttpServletRequestWrapper requestWrapper = tokenStore.buildWrapper(); + this.continueFilterChain(context, requestWrapper, res, chain); + } + + /** + * Processes a failed authentication via Keycloak. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + * @param chain + * the filter chain + * @throws IOException + * if any error occurs during processing of the filter chain + * @throws ServletException + * if any error occurs during processing of the filter chain + */ + protected void onKeycloakAuthenticationFailure(final ServletContext context, final HttpServletRequest req, + final HttpServletResponse res, final FilterChain chain) throws IOException, ServletException + { + LOGGER.warn("Keycloak authentication failed due to {}", req.getAttribute(AuthenticationError.class.getName())); + LOGGER.debug("Resetting session and state cookie before continueing with filter chain"); + + req.getSession().invalidate(); + + this.resetStateCookies(context, req, res); + + this.continueFilterChain(context, req, res, chain); + } + + /** + * Continues processing the filter chain, either directly or by delegating to the facaded default SSO filter. + * + * @param context + * the servlet context + * @param request + * the current request + * @param response + * the response to the current request + * @param chain + * the filter chain + * @throws IOException + * if any exception is propagated by a filter in the chain or the actual request processing + * @throws ServletException + * if any exception is propagated by a filter in the chain or the actual request processing + */ + protected void continueFilterChain(final ServletContext context, final ServletRequest request, final ServletResponse response, + final FilterChain chain) throws IOException, ServletException + { + final HttpSession session = ((HttpServletRequest) request).getSession(false); + final Object keycloakAccount = session != null ? session.getAttribute(KeycloakAccount.class.getName()) : null; + + // no point in forwarding to default SSO filter if already authenticated + if (this.defaultSsoFilter != null && keycloakAccount == null) + { + this.defaultSsoFilter.doFilter(context, request, response, chain); + } + else + { + chain.doFilter(request, response); + } + } + + /** + * Checks if processing of the filter must be skipped for the specified request. + * + * @param req + * the servlet request to check for potential conditions to skip + * @param res + * the servlet response on which potential updates of cookies / response headers need to be set + * @return {@code true} if processing of the {@link #doFilter(ServletContext, ServletRequest, ServletResponse, FilterChain) filter + * operation} must be skipped, {@code false} otherwise + * @throws ServletException + * if any error occurs during inspection of the request + */ + protected boolean checkForSkipCondition(final HttpServletRequest req, final HttpServletResponse res) throws ServletException + { + boolean skip = false; + + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : ""); + + final Matcher proxyMatcher = PROXY_URL_PATTERN_COMPILED.matcher(servletRequestUri); + + HttpSession currentSession = req.getSession(false); + + // check for back-channel logout (sessionIdMapper should now of all authenticated sessions) + if (this.externalAuthEnabled && this.filterEnabled && this.keycloakDeployment != null && currentSession != null + && AuthenticationUtil.isAuthenticated(req) && currentSession.getAttribute(KeycloakAccount.class.getName()) != null + && !this.sessionIdMapper.hasSession(currentSession.getId())) + { + LOGGER.debug("Session {} for Keycloak-authenticated user {} was invalidated by back-channel logout", currentSession.getId(), + AuthenticationUtil.getUserId(req)); + currentSession.invalidate(); + currentSession = req.getSession(false); + } + + if (!this.externalAuthEnabled || !this.filterEnabled) + { + LOGGER.debug("Skipping doFilter as filter and/or external authentication are not enabled"); + skip = true; + } + else if (this.keycloakDeployment == null) + { + LOGGER.debug("Skipping doFilter as Keycloak adapter was not properly initialised"); + skip = true; + } + else if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN)) + { + LOGGER.debug("Explicitly not skipping doFilter as Keycloak action URL is being called"); + } + else if (req.getParameter("state") != null && req.getParameter("code") != null && this.hasStateCookie(req)) + { + LOGGER.debug( + "Explicitly not skipping doFilter as state and code query parameters of OAuth2 redirect as well as state cookie are present"); + } + else if (req.getHeader(HEADER_AUTHORIZATION) != null && req.getHeader(HEADER_AUTHORIZATION).startsWith("Bearer ")) + { + LOGGER.debug("Explicitly not skipping doFilter as Bearer authorization header is present"); + } + else if (req.getHeader(HEADER_AUTHORIZATION) != null) + { + LOGGER.debug("Skipping doFilter as non-OIDC authorization header is present"); + skip = true; + } + else if (req.getHeader(HEADER_AUTHORIZATION) == null && (currentSession != null && AuthenticationUtil.isAuthenticated(req))) + { + final String userId = AuthenticationUtil.getUserId(req); + LOGGER.debug("Existing HTTP session is associated with user {}", userId); + + final KeycloakAccount keycloakAccount = (KeycloakAccount) currentSession.getAttribute(KeycloakAccount.class.getName()); + if (keycloakAccount != null) + { + skip = this.validateAndRefreshKeycloakAuthentication(req, res, userId, keycloakAccount); + } + else + { + LOGGER.debug("Skipping doFilter as non-Keycloak-authenticated session is already established"); + skip = true; + } + } + else if (proxyMatcher.matches()) + { + final String endpoint = proxyMatcher.group(1); + final String noauth = proxyMatcher.group(2); + if (noauth != null && !noauth.trim().isEmpty()) + { + LOGGER.debug("Skipping doFilter as proxy servlet to noauth endpoint {} is being called"); + skip = true; + } + else if (!endpoint.equals(this.primaryEndpoint) + && (this.secondaryEndpoints == null || !this.secondaryEndpoints.contains(endpoint))) + { + LOGGER.debug( + "Skipping doFilter on proxy servlet call as endpoint {} has not been configured as a primary / secondary endpoint to handle"); + skip = true; + } + } + else if (PAGE_SERVLET_PATH.equals(servletPath) && (LOGIN_PATH_INFORMATION.equals(pathInfo) + || (pathInfo == null && LOGIN_PAGE_TYPE_PARAMETER_VALUE.equals(req.getParameter(PAGE_TYPE_PARAMETER_NAME))))) + { + LOGGER.debug("Skipping doFilter as login page was explicitly requested"); + skip = true; + } + else if (this.isNoAuthPage(req)) + { + LOGGER.debug("Skipping doFilter as requested page does not require authentication"); + skip = true; + } + + return skip; + } + + /** + * Processes an existing Keycloak authentication, verifying the state of the underlying access token and potentially refreshing it if + * necessary or configured. + * + * @param req + * the HTTP servlet request + * @param res + * the HTTP servlet response + * @param userId + * the ID of the authenticated user + * @param keycloakAccount + * the Keycloak account object + * @return {@code true} if processing of the {@link #doFilter(ServletContext, ServletRequest, ServletResponse, FilterChain) filter + * operation} can be skipped as the account represents a valid and still active authentication, {@code false} otherwise + */ + protected boolean validateAndRefreshKeycloakAuthentication(final HttpServletRequest req, final HttpServletResponse res, + final String userId, final KeycloakAccount keycloakAccount) + { + HttpSession currentSession; + final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res); + + final KeycloakAuthenticationConfigElement keycloakAuthConfig = (KeycloakAuthenticationConfigElement) this.configService + .getConfig("Keycloak").getConfigElement(KeycloakAuthenticationConfigElement.NAME); + + final Integer bodyBufferLimit = keycloakAuthConfig.getBodyBufferLimit(); + final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade, + bodyBufferLimit != null ? bodyBufferLimit.intValue() : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null); + + tokenStore.checkCurrentToken(); + + currentSession = req.getSession(false); + boolean skip = false; + if (currentSession != null) + { + LOGGER.debug("Skipping doFilter as Keycloak-authentication session is still valid"); + skip = true; + + if (keycloakAccount instanceof OidcKeycloakAccount) + { + final KeycloakSecurityContext keycloakSecurityContext = ((OidcKeycloakAccount) keycloakAccount) + .getKeycloakSecurityContext(); + + final String accessTokenString = keycloakSecurityContext.getTokenString(); + + final HttpSession effectiveSession = currentSession; + this.updateEndpointConnectorBearerToken(this.primaryEndpoint, userId, effectiveSession, accessTokenString); + if (this.secondaryEndpoints != null) + { + this.secondaryEndpoints.forEach(endpoint -> { + this.updateEndpointConnectorBearerToken(endpoint, userId, effectiveSession, accessTokenString); + }); + } + } + } + else + { + LOGGER.debug("Keycloak-authenticated session for user {} was invalidated after token expiration", userId); + } + return skip; + } + + /** + * Checks if the requested page does not require user authentication. + * + * @param req + * the servlet request for which to check the authentication requirement of the target page + * @return {@code true} if the requested page does not require user authentication, + * {@code false} otherwise (incl. failure to resolve the request to a target page) + * @throws ServletException + * if any error occurs during inspection of the request + */ + protected boolean isNoAuthPage(final HttpServletRequest req) throws ServletException + { + final String pathInfo = req.getPathInfo(); + RequestContext context = null; + try + { + context = RequestContextUtil.initRequestContext(this.applicationContext, req, true); + } + catch (final Exception ex) + { + LOGGER.error("Error calling initRequestContext", ex); + throw new ServletException(ex); + } + + Page page = context.getPage(); + if (page == null && pathInfo != null) + { + try + { + if (this.pageViewResolver.resolveViewName(pathInfo, null) != null) + { + page = context.getPage(); + } + } + catch (final Exception e) + { + LOGGER.warn("Error during resolution of requested page view", e); + } + } + + boolean noAuthPage = false; + if (page != null && page.getAuthentication() == RequiredAuthentication.none) + { + noAuthPage = true; + } + return noAuthPage; + } + + /** + * Checks if the requested page is a login page. + * + * @param req + * the request for which to check the type of page + * @return {@code true} if the requested page is a login page, + * {@code false} otherwise (incl. failure to resolve the request to a target page) + * @throws ServletException + * if any error occurs during inspection of the request + */ + protected boolean isLoginPage(final HttpServletRequest req) throws ServletException + { + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + + boolean isLoginPage; + if (PAGE_SERVLET_PATH.equals(servletPath) + && (pathInfo == null && LOGIN_PAGE_TYPE_PARAMETER_VALUE.equals(req.getParameter(PAGE_TYPE_PARAMETER_NAME)))) + { + isLoginPage = true; + } + else + { + // check for custom login page + RequestContext context = null; + try + { + context = RequestContextUtil.initRequestContext(this.applicationContext, req, true); + } + catch (final Exception ex) + { + LOGGER.error("Error calling initRequestContext", ex); + throw new ServletException(ex); + } + + Page page = context.getPage(); + if (page == null && pathInfo != null) + { + try + { + if (this.pageViewResolver.resolveViewName(pathInfo, null) != null) + { + page = context.getPage(); + } + } + catch (final Exception e) + { + LOGGER.warn("Error during resolution of requested page view", e); + } + } + + isLoginPage = false; + if (page != null && page.getPageType(context) != null && PageType.PAGETYPE_LOGIN.equals(page.getPageType(context).getId())) + { + isLoginPage = true; + } + } + return isLoginPage; + } + + /** + * Checks if the requested URL indicates a logout request. + * + * @param req + * the request to check + * @return {@code true} if the request is a request for logout, + * {@code false} otherwise + * @throws ServletException + * if any error occurs during inspection of the request + */ + protected boolean isLogoutRequest(final HttpServletRequest req) throws ServletException + { + final String servletPath = req.getServletPath(); + final String pathInfo = req.getPathInfo(); + final boolean isLogoutRequest = PAGE_SERVLET_PATH.equals(servletPath) && LOGOUT_PATH_INFORMATION.equals(pathInfo); + return isLogoutRequest; + } + + protected void updateEndpointConnectorBearerToken(final String endpoint, final String userId, final HttpSession session, + final String tokenString) + { + try + { + final Connector conn = this.connectorService.getConnector(endpoint, userId, session); + conn.getConnectorSession().setParameter(BearerTokenAwareSlingshotAlfrescoConnector.CS_PARAM_BEARER_TOKEN, tokenString); + } + catch (final ConnectorServiceException e) + { + LOGGER.warn("Endpoint {} has not been defined", endpoint); + } + } + + /** + * Checks if the HTTP request has set the Keycloak state cookie. + * + * @param req + * the HTTP request to check + * @return {@code true} if the state cookie is set, {@code false} otherwise + */ + protected boolean hasStateCookie(final HttpServletRequest req) + { + final String stateCookieName = this.keycloakDeployment.getStateCookieName(); + final Cookie[] cookies = req.getCookies(); + final boolean hasStateCookie = cookies != null + ? Arrays.asList(cookies).stream().map(Cookie::getName).filter(stateCookieName::equals).findAny().isPresent() + : false; + return hasStateCookie; + } + + /** + * Resets any Keycloak-related state cookies present in the current request. + * + * @param context + * the servlet context + * @param req + * the servlet request + * @param res + * the servlet response + */ + protected void resetStateCookies(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res) + { + final Cookie[] cookies = req.getCookies(); + if (cookies != null) + { + final String stateCookieName = this.keycloakDeployment.getStateCookieName(); + Arrays.asList(cookies).stream().filter(cookie -> stateCookieName.equals(cookie.getName())).findAny().ifPresent(cookie -> { + final Cookie resetCookie = new Cookie(cookie.getName(), ""); + resetCookie.setPath(context.getContextPath()); + resetCookie.setMaxAge(0); + resetCookie.setHttpOnly(false); + resetCookie.setSecure(false); + res.addCookie(resetCookie); + }); + } + } +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/RedirectCaptureServletHttpFacade.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/RedirectCaptureServletHttpFacade.java new file mode 100644 index 0000000..a25531e --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/RedirectCaptureServletHttpFacade.java @@ -0,0 +1,192 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.web; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.alfresco.util.Pair; +import org.keycloak.adapters.servlet.ServletHttpFacade; +import org.keycloak.adapters.spi.HttpFacade; + +/** + * This {@link HttpFacade} wraps servlet requests and responses in such a way that any response headers / cookies being set by Keycloak + * authenticators are captured, and otherwise no output is written to the servlet response. This is required for some scenarios in which a + * redirect action should be included in the login form. + * + * @author Axel Faust + */ +public class RedirectCaptureServletHttpFacade extends ServletHttpFacade +{ + + protected final Map, javax.servlet.http.Cookie> cookies = new HashMap<>(); + + protected final Map> headers = new HashMap<>(); + + /** + * Creates a new instance of this class for the provided servlet request. + * + * @param request + * the servlet request to facade + */ + public RedirectCaptureServletHttpFacade(final HttpServletRequest request) + { + super(request, null); + } + + /** + * + * {@inheritDoc} + */ + @Override + public Response getResponse() + { + return new ResponseCaptureFacade(); + } + + /** + * @return the cookies + */ + public List getCookies() + { + return new ArrayList<>(this.cookies.values()); + } + + /** + * @return the headers + */ + public Map> getHeaders() + { + final Map> headers = new HashMap<>(); + this.headers.forEach((headerName, values) -> headers.put(headerName, new ArrayList<>(values))); + return headers; + } + + /** + * + * @author Axel Faust + */ + private class ResponseCaptureFacade implements Response + { + + /** + * + * {@inheritDoc} + */ + @Override + public void setStatus(final int status) + { + // NO-OP + } + + /** + * + * {@inheritDoc} + */ + @Override + public void addHeader(final String name, final String value) + { + RedirectCaptureServletHttpFacade.this.headers.computeIfAbsent(name, key -> new ArrayList<>()).add(value); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void setHeader(final String name, final String value) + { + RedirectCaptureServletHttpFacade.this.headers.put(name, new ArrayList<>(Collections.singleton(value))); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void resetCookie(final String name, final String path) + { + RedirectCaptureServletHttpFacade.this.cookies.remove(new Pair<>(name, path)); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void setCookie(final String name, final String value, final String path, final String domain, final int maxAge, + final boolean secure, final boolean httpOnly) + { + final javax.servlet.http.Cookie cookie = new javax.servlet.http.Cookie(name, value); + cookie.setPath(path); + if (domain != null) + { + cookie.setDomain(domain); + } + cookie.setMaxAge(maxAge); + cookie.setSecure(secure); + cookie.setHttpOnly(httpOnly); + RedirectCaptureServletHttpFacade.this.cookies.put(new Pair<>(name, path), cookie); + } + + /** + * + * {@inheritDoc} + */ + @Override + public OutputStream getOutputStream() + { + return new ByteArrayOutputStream(); + } + + /** + * + * {@inheritDoc} + */ + @Override + public void sendError(final int code) + { + // NO-OP + } + + /** + * + * {@inheritDoc} + */ + @Override + public void sendError(final int code, final String message) + { + // NO-OP + } + + /** + * + * {@inheritDoc} + */ + @Override + public void end() + { + // NO-OP + } + } +} diff --git a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/UserGroupsLoadFilter.java b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/UserGroupsLoadFilter.java new file mode 100644 index 0000000..9f99a1e --- /dev/null +++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/UserGroupsLoadFilter.java @@ -0,0 +1,261 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.acosix.alfresco.keycloak.share.web; + +import java.io.IOException; +import java.util.Date; + +import javax.servlet.FilterChain; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; + +import org.alfresco.error.AlfrescoRuntimeException; +import org.alfresco.util.PropertyCheck; +import org.alfresco.web.site.servlet.SlingshotLoginController; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.extensions.config.ConfigElement; +import org.springframework.extensions.config.ConfigService; +import org.springframework.extensions.surf.RequestContext; +import org.springframework.extensions.surf.RequestContextUtil; +import org.springframework.extensions.surf.exception.ConnectorServiceException; +import org.springframework.extensions.surf.exception.RequestContextException; +import org.springframework.extensions.surf.site.AuthenticationUtil; +import org.springframework.extensions.surf.support.AlfrescoUserFactory; +import org.springframework.extensions.surf.support.ThreadLocalRequestContext; +import org.springframework.extensions.surf.util.URLEncoder; +import org.springframework.extensions.webscripts.Status; +import org.springframework.extensions.webscripts.connector.Connector; +import org.springframework.extensions.webscripts.connector.ConnectorContext; +import org.springframework.extensions.webscripts.connector.ConnectorService; +import org.springframework.extensions.webscripts.connector.HttpMethod; +import org.springframework.extensions.webscripts.connector.Response; +import org.springframework.extensions.webscripts.servlet.DependencyInjectedFilter; + +/** + * This filter performs the initial load of user groups for any user authenticated by a filter preceeding it in the filter chain, and + * transparently refreshes the user groups after a configurable amount of time has past, in order to avoid Share user groups to become stale + * / inconsistent with actual group memberships in the Alfresco Repository. This filter is necessary since the default logic for the simple + * initialisation inside {@link SlingshotLoginController} is inaccessible to custom authentication filters, and there is actually no refresh + * functionality in default Alfresco at all, which can be problematic for SSO-authenticated sessions that may be active for a long time. + * + * @author Axel Faust + */ +public class UserGroupsLoadFilter implements DependencyInjectedFilter, InitializingBean, ApplicationContextAware +{ + + public static final String SESSION_ATTRIBUTE_KEY_USER_GROUPS_LAST_LOADED = SlingshotLoginController.SESSION_ATTRIBUTE_KEY_USER_GROUPS + + "_lastLoaded"; + + private static final Logger LOGGER = LoggerFactory.getLogger(UserGroupsLoadFilter.class); + + private static final long DEFAULT_CACHED_USER_GROUPS_TIMEOUT = 60000; + + protected ApplicationContext applicationContext; + + protected ConfigService configService; + + protected ConnectorService connectorService; + + /** + * {@inheritDoc} + */ + @Override + public void setApplicationContext(final ApplicationContext applicationContext) + { + this.applicationContext = applicationContext; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void afterPropertiesSet() + { + PropertyCheck.mandatory(this, "applicationContext", this.applicationContext); + PropertyCheck.mandatory(this, "configService", this.configService); + PropertyCheck.mandatory(this, "connectorService", this.connectorService); + } + + /** + * @param configService + * the configService to set + */ + public void setConfigService(final ConfigService configService) + { + this.configService = configService; + } + + /** + * @param connectorService + * the connectorService to set + */ + public void setConnectorService(final ConnectorService connectorService) + { + this.connectorService = connectorService; + } + + /** + * + * {@inheritDoc} + */ + @Override + public void doFilter(final ServletContext context, final ServletRequest request, final ServletResponse response, + final FilterChain chain) throws IOException, ServletException + { + if (request instanceof HttpServletRequest) + { + final HttpSession session = ((HttpServletRequest) request).getSession(false); + if (session != null) + { + final String userId = AuthenticationUtil.getUserId((HttpServletRequest) request); + final String userGroupsCSVList = (String) session.getAttribute(SlingshotLoginController.SESSION_ATTRIBUTE_KEY_USER_GROUPS); + + final Date lastLoaded = (Date) session.getAttribute(SESSION_ATTRIBUTE_KEY_USER_GROUPS_LAST_LOADED); + long cachedUserGroupsTimeout = DEFAULT_CACHED_USER_GROUPS_TIMEOUT; + + final ConfigElement userConfig = this.configService.getGlobalConfig().getConfigElement("user"); + if (userConfig != null) + { + final String timeoutConfig = userConfig.getChildValue("cached-user-groups-timeout"); + if (timeoutConfig != null) + { + cachedUserGroupsTimeout = Long.parseLong(timeoutConfig, 10); + } + } + + if (userId != null) + { + if (userGroupsCSVList == null + || (lastLoaded != null && lastLoaded.getTime() + cachedUserGroupsTimeout < System.currentTimeMillis())) + { + session.setAttribute(SlingshotLoginController.SESSION_ATTRIBUTE_KEY_USER_GROUPS, + this.loadUserGroupsCSVList((HttpServletRequest) request, session, userId)); + session.setAttribute(SESSION_ATTRIBUTE_KEY_USER_GROUPS_LAST_LOADED, new Date()); + } + else if (lastLoaded == null) + { + // might have just been loaded by an authentication filter on initial login + session.setAttribute(SESSION_ATTRIBUTE_KEY_USER_GROUPS_LAST_LOADED, new Date()); + } + } + } + } + + chain.doFilter(request, response); + } + + /** + * Loads the groups a user is a member of as a comma-separated list from the default Alfresco backend. + * + * @param request + * the HTTP servlet request + * @param session + * the current session + * @param userId + * the ID of the user for which to load the group memberships + * @return the list of groups the user is a member of as a comma-separated list of names + */ + protected String loadUserGroupsCSVList(final HttpServletRequest request, final HttpSession session, final String userId) + { + String userGroupsCSVList; + try + { + // logic nearly identical to SlingshotLoginController + final Connector connector = this.connectorService.getConnector(AlfrescoUserFactory.ALFRESCO_ENDPOINT_ID, userId, session); + + // bug in default Alfresco RequestCachingConnector: with ConnectorContext having HttpMethod.GET, null check of + // ThreadLocalRequestContext.getRequestContext() is short-circuited, causing NPE on access + final RequestContext requestContext = ThreadLocalRequestContext.getRequestContext(); + if (requestContext == null) + { + try + { + RequestContextUtil.initRequestContext(this.applicationContext, request, true); + } + catch (final RequestContextException e) + { + LOGGER.error("Failed to initialise request context", e); + throw new AlfrescoRuntimeException("Failed to initialise request context", e); + } + } + + final ConnectorContext c = new ConnectorContext(HttpMethod.GET); + c.setContentType("application/json"); + final Response res = connector.call("/api/people/" + URLEncoder.encode(userId) + "?groups=true", c); + + if (res.getStatus().getCode() == Status.STATUS_OK) + { + final String responseText = res.getResponse(); + final JSONParser jsonParser = new JSONParser(); + final Object userData = jsonParser.parse(responseText.toString()); + + final StringBuilder groups = new StringBuilder(512); + if (userData instanceof JSONObject) + { + final Object groupsArray = ((JSONObject) userData).get("groups"); + if (groupsArray instanceof JSONArray) + { + for (final Object groupData : (JSONArray) groupsArray) + { + if (groupData instanceof JSONObject) + { + final Object groupName = ((JSONObject) groupData).get("itemName"); + if (groupName != null) + { + if (groups.length() > 0) + { + groups.append(','); + } + groups.append(groupName.toString()); + } + } + } + } + } + + userGroupsCSVList = groups.toString(); + + LOGGER.debug("Retrieved group memberships for user {}: {}", userId, userGroupsCSVList); + } + else + { + LOGGER.warn("Failed to load user groups for {} with backend call resulting in HTTP {} response and message {}", userId, + res.getStatus().getCode(), res.getStatus().getMessage()); + userGroupsCSVList = ""; + } + } + catch (final ConnectorServiceException | ParseException ex) + { + LOGGER.error("Failed to load user groups for {}", userId, ex); + userGroupsCSVList = ""; + } + + return userGroupsCSVList; + } +} diff --git a/share/src/main/messages/.gitkeep b/share/src/main/messages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/share/src/main/resources/META-INF/web-fragment.xml b/share/src/main/resources/META-INF/web-fragment.xml new file mode 100644 index 0000000..21506cd --- /dev/null +++ b/share/src/main/resources/META-INF/web-fragment.xml @@ -0,0 +1,44 @@ + + + + + + + ${moduleId}.AddonFilters + + + + + + + + + >${moduleId}.UserGroupsLoadFilter + org.springframework.extensions.webscripts.servlet.BeanProxyFilter + + beanName + ${moduleId}.UserGroupsLoadFilter + + + + + >${moduleId}.UserGroupsLoadFilter + /* + + \ No newline at end of file diff --git a/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.html.ftl b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.html.ftl new file mode 100644 index 0000000..03fba8d --- /dev/null +++ b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.html.ftl @@ -0,0 +1,43 @@ +<#-- + Copyright 2019 Acosix GmbH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + --> + <#function showRedirectForm> + <#local showRedirect = false /> + <#local authConfig = config.scoped['Keycloak']['keycloak-auth-config'] /> + <#if authConfig?? && authConfig.enhanceLoginForm??> + <#local showRedirect = authConfig.enhanceLoginForm /> + + <#return showRedirect /> + + +<#if keycloakRedirectUriModel?? && showRedirectForm()> + <@markup id="oidc-redirect-button" target="form" action="after"> + <#assign el = args.htmlid?html> + <#-- reuse CSS for consistent look&feel --> + + + \ No newline at end of file diff --git a/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.js b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.js new file mode 100644 index 0000000..da3754f --- /dev/null +++ b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.js @@ -0,0 +1,62 @@ +/* + * Copyright 2019 Acosix GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function main() +{ + var keycloakRedirectUrl, keycloakRedirectUriModel, parameters, idx, parameter, parameterModel; + + // redirect URL is already pre-constructed for a simple redirect + // we want to support a form-based redirect for UI consistency, so need to deconstruct and even decode (parts of) URL + keycloakRedirectUrl = Packages.de.acosix.alfresco.keycloak.share.web.KeycloakAuthenticationFilter.getLoginRedirectUrl(); + if (keycloakRedirectUrl !== null) + { + // make sure it is a JS string, not Java string + keycloakRedirectUrl = String(keycloakRedirectUrl); + keycloakRedirectUriModel = { + baseUrl : keycloakRedirectUrl.substring(0, keycloakRedirectUrl.indexOf('?')), + parameters : [] + }; + parameters = keycloakRedirectUrl.substring(keycloakRedirectUrl.indexOf('?') + 1).split(/&/); + for (idx = 0; idx < parameters.length; idx++) + { + parameter = parameters[idx]; + if (parameter !== '' && parameter.indexOf('=') !== -1) + { + parameterModel = { + name : parameter.substring(0, parameter.indexOf('=')), + value : null + }; + if (parameterModel.name === 'redirect_uri') + { + parameterModel.value = decodeURIComponent(parameter.substring(parameter.indexOf('=') + 1)); + if (parameterModel.value.indexOf('?') !== -1) + { + parameterModel.value = parameterModel.value.substring(0, parameterModel.value.indexOf('?')); + } + } + else + { + parameterModel.value = parameter.substring(parameter.indexOf('=') + 1); + } + keycloakRedirectUriModel.parameters.push(parameterModel); + } + } + + model.keycloakRedirectUriModel = keycloakRedirectUriModel; + } +} + +main(); diff --git a/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.properties b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.properties new file mode 100644 index 0000000..a9e57fe --- /dev/null +++ b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get.properties @@ -0,0 +1 @@ +button.oidc-sso=Login via SSO \ No newline at end of file diff --git a/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get_de.properties b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get_de.properties new file mode 100644 index 0000000..5718fbd --- /dev/null +++ b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/components/guest/login.get_de.properties @@ -0,0 +1 @@ +button.oidc-sso=\u00dcber SSO anmelden \ No newline at end of file diff --git a/share/src/main/site-webscripts/de/acosix/keycloak/customisations/share/header/share-header.get.js b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/share/header/share-header.get.js new file mode 100644 index 0000000..cdcc4c1 --- /dev/null +++ b/share/src/main/site-webscripts/de/acosix/keycloak/customisations/share/header/share-header.get.js @@ -0,0 +1,42 @@ +var userMenu, otherMenuGroup, logoutItem; + +if (model.jsonModel && model.jsonModel.widgets + && Packages.de.acosix.alfresco.keycloak.share.web.KeycloakAuthenticationFilter.isAuthenticatedByKeycloak()) +{ + // default Share does not show logout action for externally authenticated users + // but with Keycloak we can actually support it, so add it (if missing and user menu has not been removed) + + userMenu = widgetUtils.findObject(model.jsonModel.widgets, 'id', 'HEADER_USER_MENU'); + if (userMenu) + { + otherMenuGroup = widgetUtils.findObject(model.jsonModel.widgets, 'id', 'HEADER_USER_MENU_OTHER_GROUP'); + if (!otherMenuGroup) + { + otherMenuGroup = { + id : 'HEADER_USER_MENU_OTHER_GROUP', + name : 'alfresco/menus/AlfMenuGroup', + config : { + label : 'group.other.label', + widgets : [], + additionalCssClasses : 'alf-menu-group-no-label' + } + }; + userMenu.config.widgets.push(otherMenuGroup); + } + + logoutItem = widgetUtils.findObject(model.jsonModel.widgets, 'id', 'HEADER_USER_MENU_LOGOUT'); + if (!logoutItem) + { + otherMenuGroup.config.widgets.push({ + id : 'HEADER_USER_MENU_LOGOUT', + name : 'alfresco/header/AlfMenuItem', + config : { + id : 'HEADER_USER_MENU_LOGOUT', + label : 'logout.label', + iconClass : 'alf-user-logout-icon', + publishTopic : 'ALF_DOLOGOUT' + } + }); + } + } +} diff --git a/share/src/main/templates/.gitkeep b/share/src/main/templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/share/src/main/webapp/.gitkeep b/share/src/main/webapp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/share/src/test/docker/repository-it.xml b/share/src/test/docker/repository-it.xml new file mode 100644 index 0000000..7ab1482 --- /dev/null +++ b/share/src/test/docker/repository-it.xml @@ -0,0 +1,47 @@ + + + + repository-it-docker + + dir + + false + + + + WEB-INF/lib + + de.acosix.alfresco.utility:de.acosix.alfresco.utility.common:* + de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo.quartz1:* + de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo.quartz2:* + de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.repo:jar:installable:* + ${project.groupId}:de.acosix.alfresco.keycloak.repo:jar:installable:* + + test + + + diff --git a/share/src/test/docker/share-it.xml b/share/src/test/docker/share-it.xml new file mode 100644 index 0000000..216b083 --- /dev/null +++ b/share/src/test/docker/share-it.xml @@ -0,0 +1,65 @@ + + + + share-it-docker + + dir + + false + + + ${project.build.directory} + WEB-INF/lib + + ${project.artifactId}-${project.version}-installable.jar + + + + ${project.basedir}/src/test/resources + WEB-INF/classes + + **/*.properties + **/*.xml + + true + lf + + + + + WEB-INF/lib + + org.keycloak:* + org.jboss.logging:* + org.bouncycastle:* + com.fasterxml.jackson.core:* + + compile + + + WEB-INF/lib + + de.acosix.alfresco.utility:de.acosix.alfresco.utility.common:* + de.acosix.alfresco.utility:de.acosix.alfresco.utility.core.share:jar:installable:* + + test + + + diff --git a/share/src/test/resources/alfresco/web-extension/share-config-custom.xml b/share/src/test/resources/alfresco/web-extension/share-config-custom.xml new file mode 100644 index 0000000..e23cc5f --- /dev/null +++ b/share/src/test/resources/alfresco/web-extension/share-config-custom.xml @@ -0,0 +1,29 @@ + + + + + + + + secret + test + + true + + + + \ No newline at end of file