diff --git a/pom.xml b/pom.xml
index 92e33ec..25020d6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -78,6 +78,7 @@
4.4.3
1.0.7.0
+ 1.1.0.0
@@ -176,6 +177,20 @@
installable
test
+
+
+ org.orderofthebee.support-tools
+ support-tools-repo
+ ${ootbee.support-tools.version}
+ test
+
+
+
+ org.orderofthebee.support-tools
+ support-tools-share
+ ${ootbee.support-tools.version}
+ test
+
diff --git a/repository/module.properties b/repository/module.properties
index b0ef680..0e84892 100644
--- a/repository/module.properties
+++ b/repository/module.properties
@@ -3,4 +3,6 @@ module.title=${project.name}
module.description=${project.description}
module.version=${noSnapshotVersion}
-module.repo.version.min=5
\ No newline at end of file
+module.repo.version.min=5
+
+module.depends.acosix-utility-core=1.0.3.1-*
\ No newline at end of file
diff --git a/repository/pom.xml b/repository/pom.xml
index 03436e6..b090479 100644
--- a/repository/pom.xml
+++ b/repository/pom.xml
@@ -28,22 +28,135 @@
Acosix Alfresco Keycloak - Repository Module
+
+
+ 4.6.0.Final
+ 8380
+
org.alfresco
- alfresco-repository
+ alfresco-remote-api
+
+
+
+ javax.servlet
+ javax.servlet-api
+
+
+
+
+ org.keycloak
+ keycloak-servlet-filter-adapter
+
+
+ org.bouncycastle
+ *
+
+
+ com.fasterxml.jackson.core
+ *
+
+
+
+
+
+ de.acosix.alfresco.utility
+ de.acosix.alfresco.utility.core.repo
+ installable
+
+
+
+ org.orderofthebee.support-tools
+ support-tools-repo
+
+
+
+
+
+
+
+ io.fabric8
+ docker-maven-plugin
+
+
+
+
+
+
+
+
+
+ ${docker.tests.host.name}
+
+
+
+
+
+
+ ${moduleId}-repository-test-contentstore:/usr/local/tomcat/alf_data
+ ${project.build.directory}/docker/repository-logs:/usr/local/tomcat/logs
+
+
+
+ postgres
+ keycloak
+
+
+
+
+
+
+
+
+
+
+ jboss/keycloak
+ keycloak
+
+ keycloak
+
+ admin
+ admin
+ /tmp/test-realm.json
+ h2
+
+
+ ${docker.tests.keycloakPort}:8080
+
+
+ custom
+ ${moduleId}-test
+ keycloak
+
+
+
+ ${project.build.directory}/docker/test-realm.json:/tmp/test-realm.json
+
+
+
+
+
+
+
+
+
+
+
+
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
index e69de29..e7dd9ec 100644
--- a/repository/src/main/config/alfresco-global.properties
+++ b/repository/src/main/config/alfresco-global.properties
@@ -0,0 +1,51 @@
+cache.${moduleId}.ssoToSessionCache.maxItems=10000
+cache.${moduleId}.ssoToSessionCache.timeToLiveSeconds=0
+cache.${moduleId}.ssoToSessionCache.maxIdleSeconds=0
+cache.${moduleId}.ssoToSessionCache.cluster.type=fully-distributed
+cache.${moduleId}.ssoToSessionCache.backup-count=1
+cache.${moduleId}.ssoToSessionCache.eviction-policy=LRU
+cache.${moduleId}.ssoToSessionCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy
+cache.${moduleId}.ssoToSessionCache.readBackupData=false
+# explicitly not clearable - should be cleared via Keycloak back-channel action
+cache.${moduleId}.ssoToSessionCache.clearable=false
+# replicate, not distribute
+cache.${moduleId}.ssoToSessionCache.ignite.cache.type=replicated
+
+cache.${moduleId}.sessionToSsoCache.maxItems=10000
+cache.${moduleId}.sessionToSsoCache.timeToLiveSeconds=0
+cache.${moduleId}.sessionToSsoCache.maxIdleSeconds=0
+cache.${moduleId}.sessionToSsoCache.cluster.type=fully-distributed
+cache.${moduleId}.sessionToSsoCache.backup-count=1
+cache.${moduleId}.sessionToSsoCache.eviction-policy=LRU
+cache.${moduleId}.sessionToSsoCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy
+cache.${moduleId}.sessionToSsoCache.readBackupData=false
+# explicitly not clearable - should be cleared via Keycloak back-channel action
+cache.${moduleId}.sessionToSsoCache.clearable=false
+# replicate, not distribute
+cache.${moduleId}.sessionToSsoCache.ignite.cache.type=replicated
+
+cache.${moduleId}.principalToSessionCache.maxItems=10000
+cache.${moduleId}.principalToSessionCache.timeToLiveSeconds=0
+cache.${moduleId}.principalToSessionCache.maxIdleSeconds=0
+cache.${moduleId}.principalToSessionCache.cluster.type=fully-distributed
+cache.${moduleId}.principalToSessionCache.backup-count=1
+cache.${moduleId}.principalToSessionCache.eviction-policy=LRU
+cache.${moduleId}.principalToSessionCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy
+cache.${moduleId}.principalToSessionCache.readBackupData=false
+# explicitly not clearable - should be cleared via Keycloak back-channel action
+cache.${moduleId}.principalToSessionCache.clearable=false
+# replicate, not distribute
+cache.${moduleId}.principalToSessionCache.ignite.cache.type=replicated
+
+cache.${moduleId}.sessionToPrincipalCache.maxItems=10000
+cache.${moduleId}.sessionToPrincipalCache.timeToLiveSeconds=0
+cache.${moduleId}.sessionToPrincipalCache.maxIdleSeconds=0
+cache.${moduleId}.sessionToPrincipalCache.cluster.type=fully-distributed
+cache.${moduleId}.sessionToPrincipalCache.backup-count=1
+cache.${moduleId}.sessionToPrincipalCache.eviction-policy=LRU
+cache.${moduleId}.sessionToPrincipalCache.merge-policy=com.hazelcast.map.merge.PutIfAbsentMapMergePolicy
+cache.${moduleId}.sessionToPrincipalCache.readBackupData=false
+# explicitly not clearable - should be cleared via Keycloak back-channel action
+cache.${moduleId}.sessionToPrincipalCache.clearable=false
+# replicate, not distribute
+cache.${moduleId}.sessionToPrincipalCache.ignite.cache.type=replicated
\ No newline at end of file
diff --git a/repository/src/main/config/module-context.xml b/repository/src/main/config/module-context.xml
index 3a5432d..2051032 100644
--- a/repository/src/main/config/module-context.xml
+++ b/repository/src/main/config/module-context.xml
@@ -19,6 +19,32 @@
http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/repository/src/main/globalConfig/.gitkeep b/repository/src/main/globalConfig/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml
new file mode 100644
index 0000000..2300a01
--- /dev/null
+++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication-context.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ org.alfresco.repo.security.authentication.AuthenticationComponent
+
+
+
+
+
+
+ ${server.transaction.mode.default}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
new file mode 100644
index 0000000..906b81f
--- /dev/null
+++ b/repository/src/main/globalConfig/subsystems/Authentication/keycloak/keycloak-authentication.properties
@@ -0,0 +1,25 @@
+keycloak.authentication.enabled=true
+keycloak.authentication.sso.enabled=true
+keycloak.authentication.defaultAdministratorUserNames=
+keycloak.authentication.allowTicketLogons=true
+keycloak.authentication.allowLocalBasicLogon=true
+keycloak.authentication.allowUserNamePasswordLogin=true
+keycloak.authentication.allowGuestLogin=true
+keycloak.authentication.authenticateFTP=true
+keycloak.authentication.silentValidationFailure=true
+
+keycloak.authentication.connectionTimeout=-1
+keycloak.authentication.socketTimeout=-1
+keycloak.authentication.sslRedirectPort=8443
+keycloak.authentication.bodyBufferLimit=10485760
+
+keycloak.adapter.auth-server-url=http://localhost:8180/auth
+keycloak.adapter.direct-auth-server-url=${keycloak.adapter.auth-server-url}
+keycloak.adapter.realm=alfresco
+keycloak.adapter.resource=alfresco
+keycloak.adapter.ssl-required=none
+keycloak.adapter.public-client=false
+keycloak.adapter.credentials.provider=secret
+keycloak.adapter.credentials.secret=
+
+# TODO default settings (identical to AdapterConfig defaults) to better align with default Alfresco subsystem property handling
\ No newline at end of file
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java
new file mode 100644
index 0000000..b04ad65
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationComponent.java
@@ -0,0 +1,223 @@
+/*
+ * 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.repo.authentication;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import org.alfresco.repo.management.subsystems.ActivateableBean;
+import org.alfresco.repo.security.authentication.AbstractAuthenticationComponent;
+import org.alfresco.repo.security.authentication.AuthenticationException;
+import org.alfresco.util.PropertyCheck;
+import org.keycloak.adapters.HttpClientBuilder;
+import org.keycloak.authorization.client.AuthzClient;
+import org.keycloak.authorization.client.Configuration;
+import org.keycloak.authorization.client.util.HttpResponseException;
+import org.keycloak.representations.adapters.config.AdapterConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.InitializingBean;
+
+/**
+ * @author Axel Faust
+ */
+public class KeycloakAuthenticationComponent extends AbstractAuthenticationComponent implements ActivateableBean, InitializingBean
+{
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationComponent.class);
+
+ protected boolean active;
+
+ protected boolean allowUserNamePasswordLogin;
+
+ protected boolean allowGuestLogin;
+
+ protected AdapterConfig adapterConfig;
+
+ protected int connectionTimeout;
+
+ protected int socketTimeout;
+
+ protected Configuration config;
+
+ protected AuthzClient authzClient;
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void afterPropertiesSet()
+ {
+ PropertyCheck.mandatory(this, "adapterConfig", this.adapterConfig);
+
+ if (this.allowUserNamePasswordLogin)
+ {
+ Map credentials = this.adapterConfig.getCredentials();
+ if (credentials != null)
+ {
+ credentials = new HashMap<>(credentials);
+ }
+
+ if (credentials == null || ((!credentials.containsKey("provider") || "secret".equals(credentials.get("provider")))
+ && !credentials.containsKey("secret")))
+ {
+ if (credentials == null)
+ {
+ credentials = new HashMap<>();
+ }
+ credentials.put("secret", "");
+ }
+
+ HttpClientBuilder httpClientBuilder = new HttpClientBuilder();
+ if (this.connectionTimeout > 0)
+ {
+ httpClientBuilder = httpClientBuilder.establishConnectionTimeout(this.connectionTimeout, TimeUnit.MILLISECONDS);
+ }
+ if (this.socketTimeout > 0)
+ {
+ httpClientBuilder = httpClientBuilder.socketTimeout(this.socketTimeout, TimeUnit.MILLISECONDS);
+ }
+
+ this.config = new Configuration(this.adapterConfig.getAuthServerUrl(), this.adapterConfig.getRealm(),
+ this.adapterConfig.getResource(), credentials, httpClientBuilder.build(this.adapterConfig));
+ try
+ {
+ this.authzClient = AuthzClient.create(this.config);
+ }
+ catch (final RuntimeException e)
+ {
+ if (LOGGER.isDebugEnabled())
+ {
+ LOGGER.debug("Failed to pre-instantiate Keycloak authz client", e);
+ }
+ else
+ {
+ LOGGER.warn("Failed to pre-instantiate Keycloak authz client: {}", e.getMessage());
+ }
+ }
+ }
+ }
+
+ /**
+ * @param active
+ * the active to set
+ */
+ public void setActive(final boolean active)
+ {
+ this.active = active;
+ }
+
+ /**
+ * @param allowUserNamePasswordLogin
+ * the allowUserNamePasswordLogin to set
+ */
+ public void setAllowUserNamePasswordLogin(final boolean allowUserNamePasswordLogin)
+ {
+ this.allowUserNamePasswordLogin = allowUserNamePasswordLogin;
+ }
+
+ /**
+ * @param allowGuestLogin
+ * the allowGuestLogin to set
+ */
+ public void setAllowGuestLogin(final boolean allowGuestLogin)
+ {
+ this.allowGuestLogin = allowGuestLogin;
+ }
+
+ /**
+ * @param adapterConfig
+ * the adapterConfig to set
+ */
+ public void setAdapterConfig(final AdapterConfig adapterConfig)
+ {
+ this.adapterConfig = adapterConfig;
+ }
+
+ /**
+ * @param connectionTimeout
+ * the connectionTimeout to set
+ */
+ public void setConnectionTimeout(final int connectionTimeout)
+ {
+ this.connectionTimeout = connectionTimeout;
+ }
+
+ /**
+ * @param socketTimeout
+ * the socketTimeout to set
+ */
+ public void setSocketTimeout(final int socketTimeout)
+ {
+ this.socketTimeout = socketTimeout;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isActive()
+ {
+ return this.active;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void authenticateImpl(final String userName, final char[] password) throws AuthenticationException
+ {
+ if (!this.allowUserNamePasswordLogin)
+ {
+ throw new AuthenticationException("Simple login via user name + password is not allowed");
+ }
+
+ if (this.authzClient == null)
+ {
+ try
+ {
+ this.authzClient = AuthzClient.create(this.config);
+ }
+ catch (final RuntimeException e)
+ {
+ LOGGER.warn("Failed to pre-instantiate Keycloak authz client", e);
+ throw new AuthenticationException("Keycloak authentication cannot be performed", e);
+ }
+ }
+
+ try
+ {
+ this.authzClient.obtainAccessToken(userName, new String(password));
+ this.setCurrentUser(userName);
+ }
+ catch (final HttpResponseException e)
+ {
+ LOGGER.debug("Failed to authenticate user against Keycloak. Status: {} Reason: {}", e.getStatusCode(), e.getReasonPhrase());
+ throw new AuthenticationException("Failed to authenticate user against Keycloak.", e);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected boolean implementationAllowsGuestLogin()
+ {
+ return this.allowGuestLogin;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java
new file mode 100644
index 0000000..0973392
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakAuthenticationFilter.java
@@ -0,0 +1,778 @@
+/*
+ * 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.repo.authentication;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+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.repo.SessionUser;
+import org.alfresco.repo.management.subsystems.ActivateableBean;
+import org.alfresco.repo.security.authentication.AuthenticationException;
+import org.alfresco.repo.security.authentication.AuthenticationUtil;
+import org.alfresco.repo.security.authentication.Authorization;
+import org.alfresco.repo.web.auth.BasicAuthCredentials;
+import org.alfresco.repo.web.auth.TicketCredentials;
+import org.alfresco.repo.web.auth.UnknownCredentials;
+import org.alfresco.repo.web.filter.beans.DependencyInjectedFilter;
+import org.alfresco.repo.webdav.auth.AuthenticationDriver;
+import org.alfresco.repo.webdav.auth.BaseAuthenticationFilter;
+import org.alfresco.repo.webdav.auth.BaseSSOAuthenticationFilter;
+import org.alfresco.util.PropertyCheck;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.keycloak.KeycloakSecurityContext;
+import org.keycloak.adapters.AdapterDeploymentContext;
+import org.keycloak.adapters.AuthenticatedActionsHandler;
+import org.keycloak.adapters.KeycloakDeployment;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.InitializingBean;
+
+/**
+ * This class provides a Keycloak-based authentication filter which can be used in the role of both global and WebDAV authentication filter.
+ *
+ * This class does not use the Alfresco default base {@link BaseSSOAuthenticationFilter SSO} filter class as a base class for inheritance
+ * since these classes are extremely NTLM / Kerberos centric and would require extremely weird hacks / workarounds to use its constraints to
+ * implement a Keycloak-based authentication.
+ *
+ * @author Axel Faust
+ */
+public class KeycloakAuthenticationFilter extends BaseAuthenticationFilter
+ implements InitializingBean, ActivateableBean, DependencyInjectedFilter
+{
+
+ private static final String HEADER_AUTHORIZATION = "Authorization";
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAuthenticationFilter.class);
+
+ private static final String KEYCLOAK_ACTION_URL_PATTERN = "^(?:/wcs(?:ervice)?)?/keycloak/k_[^/]+$";
+
+ private static final int DEFAULT_BODY_BUFFER_LIMIT = 32 * 1024;// 32 KiB
+
+ protected boolean active;
+
+ protected boolean allowTicketLogon;
+
+ protected boolean allowLocalBasicLogon;
+
+ protected String loginPageUrl;
+
+ protected int bodyBufferLimit = DEFAULT_BODY_BUFFER_LIMIT;
+
+ // use 8443 as default SSL redirect based on Tomcat default server.xml configuration
+ // can't rely on SysAdminParams#getAlfrescoPort either because that may be proxied / non-SSL
+ protected int sslRedirectPort = 8443;
+
+ protected KeycloakDeployment keycloakDeployment;
+
+ protected SessionIdMapper sessionIdMapper;
+
+ protected AdapterDeploymentContext deploymentContext;
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void afterPropertiesSet()
+ {
+ PropertyCheck.mandatory(this, "keycloakDeployment", this.keycloakDeployment);
+ PropertyCheck.mandatory(this, "sessionIdMapper", this.sessionIdMapper);
+
+ // parent class does not check, so we do
+ PropertyCheck.mandatory(this, "authenticationService", this.authenticationService);
+ PropertyCheck.mandatory(this, "authenticationComponent", this.authenticationComponent);
+ PropertyCheck.mandatory(this, "authenticationListener", this.authenticationListener);
+ PropertyCheck.mandatory(this, "personService", this.personService);
+ PropertyCheck.mandatory(this, "nodeService", this.nodeService);
+ PropertyCheck.mandatory(this, "transactionService", this.transactionService);
+
+ this.deploymentContext = new AdapterDeploymentContext(this.keycloakDeployment);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isActive()
+ {
+ return this.active;
+ }
+
+ /**
+ * @param active
+ * the active to set
+ */
+ public void setActive(final boolean active)
+ {
+ this.active = active;
+ }
+
+ /**
+ * @param allowTicketLogon
+ * the allowTicketLogon to set
+ */
+ public void setAllowTicketLogon(final boolean allowTicketLogon)
+ {
+ this.allowTicketLogon = allowTicketLogon;
+ }
+
+ /**
+ * @param allowLocalBasicLogon
+ * the allowLocalBasicLogon to set
+ */
+ public void setAllowLocalBasicLogon(final boolean allowLocalBasicLogon)
+ {
+ this.allowLocalBasicLogon = allowLocalBasicLogon;
+ }
+
+ /**
+ * @param loginPageUrl
+ * the loginPageUrl to set
+ */
+ public void setLoginPageUrl(final String loginPageUrl)
+ {
+ this.loginPageUrl = loginPageUrl;
+ }
+
+ /**
+ * @param bodyBufferLimit
+ * the bodyBufferLimit to set
+ */
+ public void setBodyBufferLimit(final int bodyBufferLimit)
+ {
+ this.bodyBufferLimit = bodyBufferLimit;
+ }
+
+ /**
+ * @param sslRedirectPort
+ * the sslRedirectPort to set
+ */
+ public void setSslRedirectPort(final int sslRedirectPort)
+ {
+ this.sslRedirectPort = sslRedirectPort;
+ }
+
+ /**
+ * @param keycloakDeployment
+ * the keycloakDeployment to set
+ */
+ public void setKeycloakDeployment(final KeycloakDeployment keycloakDeployment)
+ {
+ this.keycloakDeployment = keycloakDeployment;
+ }
+
+ /**
+ * @param sessionIdMapper
+ * the sessionIdMapper to set
+ */
+ public void setSessionIdMapper(final SessionIdMapper sessionIdMapper)
+ {
+ this.sessionIdMapper = sessionIdMapper;
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void doFilter(final ServletContext context, final ServletRequest request, final ServletResponse response,
+ final FilterChain chain) throws IOException, ServletException
+ {
+ final HttpServletRequest req = (HttpServletRequest) request;
+ final HttpServletResponse res = (HttpServletResponse) response;
+
+ final boolean skip = this.checkForSkipCondition(context, req, res);
+
+ if (skip)
+ {
+ chain.doFilter(request, response);
+ }
+ else
+ {
+ if (!this.checkAndProcessLocalBasicAuthentication(req))
+ {
+ this.processKeycloakAuthenticationAndActions(context, req, res, chain);
+ }
+ else
+ {
+ chain.doFilter(request, response);
+ }
+ }
+ }
+
+ /**
+ * Checks and processes any HTTP Basic authentication against the local Alfresco authentication services if allowed.
+ *
+ * @param req
+ * the servlet request
+ *
+ * @throws IOException
+ * if any error occurs during processing of HTTP Basic authentication
+ * @throws ServletException
+ * if any error occurs during processing of HTTP Basic authentication
+ *
+ * @return {@code true} if an existing HTTP Basic authentication header was successfully processed against the local Alfresco
+ * authentication services, {@code false} otherwise
+ */
+ protected boolean checkAndProcessLocalBasicAuthentication(final HttpServletRequest req) throws IOException, ServletException
+ {
+ boolean basicAuthSucessfull = false;
+ final String authHeader = req.getHeader(HEADER_AUTHORIZATION);
+ if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("basic "))
+ {
+ final String basicAuth = new String(Base64.decodeBase64(authHeader.substring(6).getBytes(StandardCharsets.UTF_8)),
+ StandardCharsets.UTF_8);
+
+ String userName;
+ String password = "";
+
+ final int pos = basicAuth.indexOf(":");
+ if (pos != -1)
+ {
+ userName = basicAuth.substring(0, pos);
+ password = basicAuth.substring(pos + 1);
+ }
+ else
+ {
+ userName = basicAuth;
+ }
+
+ try
+ {
+ if (userName.equalsIgnoreCase(Authorization.TICKET_USERID))
+ {
+ if (this.allowTicketLogon)
+ {
+ LOGGER.trace("Performing HTTP Basic ticket validation");
+ this.authenticationService.validate(password);
+
+ this.createUserEnvironment(req.getSession(), this.authenticationService.getCurrentUserName(),
+ this.authenticationService.getCurrentTicket(), false);
+
+ LOGGER.debug("Authenticated user {} via HTTP Basic authentication using an authentication ticket",
+ AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName()));
+
+ this.authenticationListener.userAuthenticated(new TicketCredentials(password));
+
+ basicAuthSucessfull = true;
+ }
+ else
+ {
+ LOGGER.debug("Ticket in HTTP Basic authentication header detected but ticket logon is disabled");
+ }
+ }
+ else if (this.allowLocalBasicLogon)
+ {
+ LOGGER.trace("Performing HTTP Basic user authentication against local Alfresco services");
+
+ this.authenticationService.authenticate(userName, password.toCharArray());
+
+ this.createUserEnvironment(req.getSession(), this.authenticationService.getCurrentUserName(),
+ this.authenticationService.getCurrentTicket(), false);
+
+ LOGGER.debug("Authenticated user {} via HTTP Basic authentication using locally stored credentials",
+ AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName()));
+
+ this.authenticationListener.userAuthenticated(new BasicAuthCredentials(userName, password));
+
+ basicAuthSucessfull = true;
+ }
+ }
+ catch (final AuthenticationException e)
+ {
+ LOGGER.debug("HTTP Basic authentication against local Alfresco services failed", e);
+
+ if (userName.equalsIgnoreCase(Authorization.TICKET_USERID))
+ {
+ this.authenticationListener.authenticationFailed(new TicketCredentials(password), e);
+ }
+ else
+ {
+ this.authenticationListener.authenticationFailed(new BasicAuthCredentials(userName, password), e);
+ }
+ }
+ }
+ return basicAuthSucessfull;
+ }
+
+ /**
+ * 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.trace("Processing Keycloak authentication and actions on request to {}", req.getRequestURL());
+
+ 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.trace("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,
+ this.bodyBufferLimit > 0 ? this.bodyBufferLimit : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, this.sessionIdMapper);
+ final FilterRequestAuthenticator authenticator = new FilterRequestAuthenticator(this.keycloakDeployment, tokenStore, facade, req,
+ this.sslRedirectPort);
+ final AuthOutcome authOutcome = authenticator.authenticate();
+
+ if (authOutcome == AuthOutcome.AUTHENTICATED)
+ {
+ this.onKeycloakAuthenticationSuccess(context, req, res, chain, facade, tokenStore);
+ }
+ else if (authOutcome == AuthOutcome.NOT_ATTEMPTED)
+ {
+ LOGGER.trace("No authentication took place - sending authentication challenge");
+ authenticator.getChallenge().challenge(facade);
+ }
+ else if (authOutcome == AuthOutcome.FAILED)
+ {
+ this.onKeycloakAuthenticationFailure(context, req, res);
+
+ LOGGER.trace("Sending authentication challenge from failure");
+ authenticator.getChallenge().challenge(facade);
+ }
+ }
+
+ /**
+ * 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", AuthenticationUtil.maskUsername(userId));
+
+ final SessionUser sessionUser = this.createUserEnvironment(session, userId);
+ // need different attribute name than default for integration with web scripts framework
+ // default attribute name seems to be no longer used
+ session.setAttribute(AuthenticationDriver.AUTHENTICATION_USER, sessionUser);
+
+ this.authenticationListener.userAuthenticated(new KeycloakCredentials(accessToken));
+ }
+
+ if (facade.isEnded())
+ {
+ LOGGER.debug("Keycloak authenticator processed the request - stopping filter chain execution");
+ 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.trace("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.trace("Continueing with filter chain processing");
+ final HttpServletRequestWrapper requestWrapper = tokenStore.buildWrapper();
+ chain.doFilter(requestWrapper, res);
+ }
+
+ /**
+ * Processes a failed authentication via Keycloak.
+ *
+ * @param context
+ * the servlet context
+ * @param req
+ * the servlet request
+ * @param res
+ * the servlet response
+ *
+ * @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) throws IOException, ServletException
+ {
+ final Object authenticationError = req.getAttribute(AuthenticationError.class.getName());
+ if (authenticationError != null)
+ {
+ LOGGER.warn("Keycloak authentication failed due to {}", authenticationError);
+ }
+ LOGGER.trace("Resetting session and state cookie before continueing with filter chain");
+
+ req.getSession().invalidate();
+
+ this.resetStateCookies(context, req, res);
+
+ this.authenticationListener.authenticationFailed(new UnknownCredentials());
+ }
+
+ /**
+ * Checks if processing of the filter must be skipped for the specified request.
+ *
+ * @param context
+ * the servlet context
+ * @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 IOException
+ * if any error occurs during inspection of the request
+ * @throws ServletException
+ * if any error occurs during inspection of the request
+ */
+ protected boolean checkForSkipCondition(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res)
+ throws IOException, ServletException
+ {
+ boolean skip = false;
+
+ final String authHeader = req.getHeader(HEADER_AUTHORIZATION);
+
+ final String servletPath = req.getServletPath();
+ final String pathInfo = req.getPathInfo();
+ final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : "");
+
+ final SessionUser sessionUser = this.getSessionUser(context, req, res, true);
+ HttpSession session = req.getSession();
+
+ // check for back-channel logout (sessionIdMapper should now of all authenticated sessions)
+ if (this.active && sessionUser != null && session.getAttribute(KeycloakAccount.class.getName()) != null
+ && !this.sessionIdMapper.hasSession(session.getId()))
+ {
+ LOGGER.debug("Session {} for Keycloak-authenticated user {} was invalidated by back-channel logout", session.getId(),
+ AuthenticationUtil.maskUsername(sessionUser.getUserName()));
+ this.invalidateSession(req);
+ session = req.getSession(false);
+ }
+
+ if (!this.active)
+ {
+ LOGGER.trace("Skipping doFilter as filter is not active");
+ skip = true;
+ }
+ else if (req.getAttribute(NO_AUTH_REQUIRED) != null)
+ {
+ LOGGER.trace("Skipping doFilter as filter higher up in chain determined authentication as not required");
+ }
+ else if (servletRequestUri.matches(KEYCLOAK_ACTION_URL_PATTERN))
+ {
+ LOGGER.trace("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.trace(
+ "Explicitly not skipping doFilter as state and code query parameters of OAuth2 redirect as well as state cookie are present");
+ }
+ else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("bearer "))
+ {
+ LOGGER.trace("Explicitly not skipping doFilter as Bearer authorization header is present");
+ }
+ else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("basic "))
+ {
+ LOGGER.trace("Explicitly not skipping doFilter as Basic authorization header is present");
+ }
+ else if (authHeader != null)
+ {
+ LOGGER.trace("Skipping doFilter as non-OIDC / non-Basic authorization header is present");
+ skip = true;
+ }
+ else if (this.allowTicketLogon && this.checkForTicketParameter(context, req, res))
+ {
+ LOGGER.trace("Skipping doFilter as user was authenticated by ticket URL parameter");
+ }
+ else if (sessionUser != null)
+ {
+ final KeycloakAccount keycloakAccount = (KeycloakAccount) session.getAttribute(KeycloakAccount.class.getName());
+ if (keycloakAccount != null)
+ {
+ skip = this.validateAndRefreshKeycloakAuthentication(req, res, sessionUser.getUserName(), keycloakAccount);
+ }
+ else
+ {
+ LOGGER.trace("Skipping doFilter as non-Keycloak-authenticated session is already established");
+ skip = true;
+ }
+ }
+ // TODO Check for login page URL (rarely configured since Repository by default has no login page since 5.0)
+
+ 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)
+ {
+ final OIDCServletHttpFacade facade = new OIDCServletHttpFacade(req, res);
+
+ final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, facade,
+ this.bodyBufferLimit > 0 ? this.bodyBufferLimit : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null);
+
+ final String oldSessionId = req.getSession().getId();
+
+ tokenStore.checkCurrentToken();
+
+ final HttpSession currentSession = req.getSession(false);
+
+ boolean skip = false;
+ if (currentSession != null)
+ {
+ LOGGER.trace("Skipping doFilter as Keycloak-authentication session is still valid");
+ skip = true;
+ }
+ else
+ {
+ this.sessionIdMapper.removeSession(oldSessionId);
+ LOGGER.debug("Keycloak-authenticated session for user {} was invalidated after token expiration",
+ AuthenticationUtil.maskUsername(userId));
+ }
+ return skip;
+ }
+
+ /**
+ * Check if the request has specified a ticket parameter to bypass the standard authentication.
+ *
+ * @param context
+ * the servlet context
+ * @param req
+ * the request
+ * @param resp
+ * the response
+ *
+ * @throws IOException
+ * if any error occurs during ticket processing
+ * @throws ServletException
+ * if any error occurs during ticket processing
+ *
+ * @return boolean
+ */
+ // copied + adapted from BaseSSOAuthenticationFilter
+ protected boolean checkForTicketParameter(final ServletContext context, final HttpServletRequest req, final HttpServletResponse resp)
+ throws IOException, ServletException
+ {
+ boolean ticketValid = false;
+ final String ticket = req.getParameter(ARG_TICKET);
+
+ if (ticket != null && ticket.length() != 0)
+ {
+ LOGGER.trace("Logon via ticket from {} ({}:{}) ticket={}", req.getRemoteHost(), req.getRemoteAddr(), req.getRemotePort(),
+ ticket);
+
+ try
+ {
+ final SessionUser user = this.getSessionUser(context, req, resp, true);
+
+ if (user != null && !ticket.equals(user.getTicket()))
+ {
+ LOGGER.debug("Invalidating current session as URL-provided authentication ticket does not match");
+ this.invalidateSession(req);
+ }
+
+ if (user == null)
+ {
+ this.authenticationService.validate(ticket);
+
+ this.createUserEnvironment(req.getSession(), this.authenticationService.getCurrentUserName(),
+ this.authenticationService.getCurrentTicket(), true);
+
+ LOGGER.debug("Authenticated user {} via URL-provided authentication ticket",
+ AuthenticationUtil.maskUsername(this.authenticationService.getCurrentUserName()));
+
+ this.authenticationListener.userAuthenticated(new TicketCredentials(ticket));
+ }
+
+ ticketValid = true;
+ }
+ catch (final AuthenticationException authErr)
+ {
+ LOGGER.debug("Failed to authenticate user ticket: {}", authErr.getMessage(), authErr);
+
+ this.authenticationListener.authenticationFailed(new TicketCredentials(ticket), authErr);
+ }
+ }
+
+ return ticketValid;
+ }
+
+ /**
+ * 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);
+ });
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ protected Log getLogger()
+ {
+ return LogFactory.getLog(KeycloakAuthenticationFilter.class);
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java
new file mode 100644
index 0000000..eaed9c1
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakCredentials.java
@@ -0,0 +1,75 @@
+/*
+ * 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.repo.authentication;
+
+import org.alfresco.repo.web.auth.WebCredentials;
+import org.alfresco.util.ParameterCheck;
+import org.keycloak.representations.AccessToken;
+
+/**
+ * @author Axel Faust
+ */
+public class KeycloakCredentials implements WebCredentials
+{
+
+ private static final long serialVersionUID = -4815212606223856908L;
+
+ private final AccessToken accessToken;
+
+ public KeycloakCredentials(final AccessToken accessToken)
+ {
+ ParameterCheck.mandatory("accessToken", accessToken);
+ this.accessToken = accessToken;
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public int hashCode()
+ {
+ final int prime = 31;
+ int result = 1;
+ result = prime * result + this.accessToken.getId().hashCode();
+ return result;
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean equals(final Object obj)
+ {
+ if (this == obj)
+ {
+ return true;
+ }
+ if (obj == null)
+ {
+ return false;
+ }
+ if (this.getClass() != obj.getClass())
+ {
+ return false;
+ }
+
+ final KeycloakCredentials other = (KeycloakCredentials) obj;
+ final boolean equal = this.accessToken.getId().equals(other.accessToken.getId());
+ return equal;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java
new file mode 100644
index 0000000..3a7cf8f
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/KeycloakRemoteUserMapper.java
@@ -0,0 +1,159 @@
+/*
+ * 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.repo.authentication;
+
+import java.util.List;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.alfresco.repo.management.subsystems.ActivateableBean;
+import org.alfresco.repo.security.authentication.AuthenticationException;
+import org.alfresco.repo.security.authentication.AuthenticationUtil;
+import org.alfresco.repo.security.authentication.external.RemoteUserMapper;
+import org.alfresco.service.cmr.security.PersonService;
+import org.alfresco.util.PropertyCheck;
+import org.keycloak.adapters.BearerTokenRequestAuthenticator;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.spi.AuthOutcome;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.InitializingBean;
+
+/**
+ * @author Axel Faust
+ */
+public class KeycloakRemoteUserMapper implements RemoteUserMapper, ActivateableBean, InitializingBean
+{
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakRemoteUserMapper.class);
+
+ protected boolean active;
+
+ protected boolean validationFailureSilent;
+
+ protected KeycloakDeployment keycloakDeployment;
+
+ protected PersonService personService;
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void afterPropertiesSet()
+ {
+ PropertyCheck.mandatory(this, "keycloakDeployment", this.keycloakDeployment);
+ PropertyCheck.mandatory(this, "personService", this.personService);
+
+ this.keycloakDeployment.setBearerOnly(true);
+ }
+
+ /**
+ * @param active
+ * the active to set
+ */
+ public void setActive(final boolean active)
+ {
+ this.active = active;
+ }
+
+ /**
+ * @param validationFailureSilent
+ * the validationFailureSilent to set
+ */
+ public void setValidationFailureSilent(final boolean validationFailureSilent)
+ {
+ this.validationFailureSilent = validationFailureSilent;
+ }
+
+ /**
+ * @param keycloakDeployment
+ * the keycloakDeployment to set
+ */
+ public void setKeycloakDeployment(final KeycloakDeployment keycloakDeployment)
+ {
+ this.keycloakDeployment = keycloakDeployment;
+ }
+
+ /**
+ * @param personService
+ * the personService to set
+ */
+ public void setPersonService(final PersonService personService)
+ {
+ this.personService = personService;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isActive()
+ {
+ return this.active;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getRemoteUser(final HttpServletRequest request)
+ {
+ String remoteUser = null;
+ if (this.active)
+ {
+ final ResponseHeaderCookieCaptureServletHttpFacade httpFacade = new ResponseHeaderCookieCaptureServletHttpFacade(request);
+ final BearerTokenRequestAuthenticator authenticator = new BearerTokenRequestAuthenticator(this.keycloakDeployment);
+ final AuthOutcome authOutcome = authenticator.authenticate(httpFacade);
+
+ if (authOutcome == AuthOutcome.AUTHENTICATED)
+ {
+ final String preferredUsername = authenticator.getToken().getPreferredUsername();
+ final String normalisedUserName = AuthenticationUtil
+ .runAsSystem(() -> this.personService.getUserIdentifier(preferredUsername));
+
+ LOGGER.debug("Authenticated user {} via bearer token, normalised as {}", preferredUsername, normalisedUserName);
+
+ remoteUser = normalisedUserName;
+ }
+ else if (authOutcome == AuthOutcome.FAILED)
+ {
+ authenticator.getChallenge().challenge(httpFacade);
+ final List authenticateHeader = httpFacade.getHeaders().get("WWW-Authenticate");
+ String errorDescription = null;
+ if (authenticateHeader != null && !authenticateHeader.isEmpty())
+ {
+ final String headerValue = authenticateHeader.get(0);
+ final int idx = headerValue.indexOf(", error_description=\"");
+ if (idx != -1)
+ {
+ final int startIdx = idx + ", error_description=\"".length();
+ errorDescription = headerValue.substring(startIdx, headerValue.indexOf('"', startIdx));
+ }
+ }
+
+ LOGGER.debug("Bearer token authentication failed due to: {}", errorDescription);
+
+ if (!this.validationFailureSilent)
+ {
+ throw new AuthenticationException("Token validation failed: " + errorDescription);
+ }
+ }
+ }
+
+ return remoteUser;
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java
new file mode 100644
index 0000000..23a674e
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/ResponseHeaderCookieCaptureServletHttpFacade.java
@@ -0,0 +1,213 @@
+/*
+ * 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.repo.authentication;
+
+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 ResponseHeaderCookieCaptureServletHttpFacade extends ServletHttpFacade
+{
+
+ protected final Map, javax.servlet.http.Cookie> cookies = new HashMap<>();
+
+ protected final Map> headers = new HashMap<>();
+
+ protected int status = -1;
+
+ protected String message;
+
+ /**
+ * Creates a new instance of this class for the provided servlet request.
+ *
+ * @param request
+ * the servlet request to facade
+ */
+ public ResponseHeaderCookieCaptureServletHttpFacade(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;
+ }
+
+ /**
+ * @return the status
+ */
+ public int getStatus()
+ {
+ return this.status;
+ }
+
+ /**
+ * @return the message
+ */
+ public String getMessage()
+ {
+ return this.message;
+ }
+
+ /**
+ *
+ * @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)
+ {
+ ResponseHeaderCookieCaptureServletHttpFacade.this.headers.computeIfAbsent(name, key -> new ArrayList<>()).add(value);
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void setHeader(final String name, final String value)
+ {
+ ResponseHeaderCookieCaptureServletHttpFacade.this.headers.put(name, new ArrayList<>(Collections.singleton(value)));
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void resetCookie(final String name, final String path)
+ {
+ ResponseHeaderCookieCaptureServletHttpFacade.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);
+ ResponseHeaderCookieCaptureServletHttpFacade.this.cookies.put(new Pair<>(name, path), cookie);
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public OutputStream getOutputStream()
+ {
+ return new ByteArrayOutputStream();
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void sendError(final int code)
+ {
+ ResponseHeaderCookieCaptureServletHttpFacade.this.status = code;
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void sendError(final int code, final String message)
+ {
+ ResponseHeaderCookieCaptureServletHttpFacade.this.status = code;
+ ResponseHeaderCookieCaptureServletHttpFacade.this.message = message;
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void end()
+ {
+ // NO-OP
+ }
+ }
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java
new file mode 100644
index 0000000..0af6c74
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/authentication/SimpleCacheBackedSessionIdMapper.java
@@ -0,0 +1,186 @@
+/*
+ * 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.repo.authentication;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.alfresco.repo.cache.SimpleCache;
+import org.alfresco.util.PropertyCheck;
+import org.keycloak.adapters.spi.SessionIdMapper;
+import org.springframework.beans.factory.InitializingBean;
+
+/**
+ * @author Axel Faust
+ */
+public class SimpleCacheBackedSessionIdMapper implements SessionIdMapper, InitializingBean
+{
+
+ protected SimpleCache ssoToSession;
+
+ protected SimpleCache sessionToSso;
+
+ protected SimpleCache> principalToSession;
+
+ protected SimpleCache sessionToPrincipal;
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void afterPropertiesSet()
+ {
+ PropertyCheck.mandatory(this, "ssoToSession", this.ssoToSession);
+ PropertyCheck.mandatory(this, "sessionToSso", this.sessionToSso);
+ PropertyCheck.mandatory(this, "principalToSession", this.principalToSession);
+ PropertyCheck.mandatory(this, "sessionToPrincipal", this.sessionToPrincipal);
+ }
+
+ /**
+ * @param ssoToSession
+ * the ssoToSession to set
+ */
+ public void setSsoToSession(final SimpleCache ssoToSession)
+ {
+ this.ssoToSession = ssoToSession;
+ }
+
+ /**
+ * @param sessionToSso
+ * the sessionToSso to set
+ */
+ public void setSessionToSso(final SimpleCache sessionToSso)
+ {
+ this.sessionToSso = sessionToSso;
+ }
+
+ /**
+ * @param principalToSession
+ * the principalToSession to set
+ */
+ public void setPrincipalToSession(final SimpleCache> principalToSession)
+ {
+ this.principalToSession = principalToSession;
+ }
+
+ /**
+ * @param sessionToPrincipal
+ * the sessionToPrincipal to set
+ */
+ public void setSessionToPrincipal(final SimpleCache sessionToPrincipal)
+ {
+ this.sessionToPrincipal = sessionToPrincipal;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean hasSession(final String id)
+ {
+ final boolean hasSession = this.sessionToSso.contains(id) || this.sessionToPrincipal.contains(id);
+ return hasSession;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void clear()
+ {
+ this.ssoToSession.clear();
+ this.sessionToSso.clear();
+ this.principalToSession.clear();
+ this.sessionToPrincipal.clear();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Set getUserSessions(final String principal)
+ {
+ Set userSessions = Collections.emptySet();
+ final Set lookup = this.principalToSession.get(principal);
+ if (lookup != null)
+ {
+ userSessions = new HashSet<>(lookup);
+ }
+ return userSessions;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public String getSessionFromSSO(final String sso)
+ {
+ return this.ssoToSession.get(sso);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void map(final String sso, final String principal, final String session)
+ {
+ if (sso != null)
+ {
+ this.ssoToSession.put(sso, session);
+ this.sessionToSso.put(session, sso);
+ }
+
+ if (principal != null)
+ {
+ Set userSessions = this.principalToSession.get(principal);
+ if (userSessions == null)
+ {
+ userSessions = new HashSet<>();
+ this.principalToSession.put(principal, userSessions);
+
+ }
+ userSessions.add(session);
+ this.sessionToPrincipal.put(session, principal);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void removeSession(final String session)
+ {
+ final String sso = this.sessionToSso.get(session);
+ this.sessionToSso.remove(session);
+ if (sso != null)
+ {
+ this.ssoToSession.remove(sso);
+ }
+ final String principal = this.sessionToPrincipal.get(session);
+ this.sessionToPrincipal.remove(session);
+ if (principal != null)
+ {
+ final Set sessions = this.principalToSession.get(principal);
+ sessions.remove(session);
+ if (sessions.isEmpty())
+ {
+ this.principalToSession.remove(principal);
+ }
+ }
+ }
+
+}
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java
new file mode 100644
index 0000000..db72960
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakAdapterConfigBeanFactory.java
@@ -0,0 +1,325 @@
+/*
+ * 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.repo.spring;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+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.Locale;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import org.alfresco.error.AlfrescoRuntimeException;
+import org.alfresco.util.PropertyCheck;
+import org.keycloak.representations.adapters.config.AdapterConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.config.PlaceholderConfigurerSupport;
+import org.springframework.util.PropertyPlaceholderHelper;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * @author Axel Faust
+ */
+public class KeycloakAdapterConfigBeanFactory implements FactoryBean, InitializingBean
+{
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(KeycloakAdapterConfigBeanFactory.class);
+
+ private static final Map SETTER_BY_CONFIG_NAME;
+
+ private static final Map> VALUE_TYPE_BY_CONFIG_NAME;
+
+ private static final List CONFIG_NAMES;
+
+ static
+ {
+ final Map setterByConfigName = 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();
+
+ final String fieldName = field.getName();
+ final StringBuilder setterNameBuilder = new StringBuilder(3 + fieldName.length());
+ setterNameBuilder.append("set");
+ setterNameBuilder.append(fieldName.substring(0, 1).toUpperCase(Locale.ENGLISH));
+ setterNameBuilder.append(fieldName.substring(1));
+ final String setterName = setterNameBuilder.toString();
+
+ Class> valueType = field.getType();
+ try
+ {
+ final Method setter = cls.getDeclaredMethod(setterName, valueType);
+
+ if (valueType.isPrimitive())
+ {
+ valueType = primitiveWrapperTypeMap.get(valueType);
+ }
+
+ if (supportedValueTypes.contains(valueType))
+ {
+ setterByConfigName.put(configName, setter);
+ valueTypeByConfigName.put(configName, valueType);
+ configNames.add(configName);
+ }
+ }
+ catch (final NoSuchMethodException nsme)
+ {
+ LOGGER.warn("Cannot support Keycloak adapter config field {} as no appropriate setter {} could be found in {}",
+ fieldName, setterName, cls);
+ }
+ }
+ }
+
+ cls = cls.getSuperclass();
+ }
+
+ SETTER_BY_CONFIG_NAME = Collections.unmodifiableMap(setterByConfigName);
+ VALUE_TYPE_BY_CONFIG_NAME = Collections.unmodifiableMap(valueTypeByConfigName);
+ CONFIG_NAMES = Collections.unmodifiableList(configNames);
+ }
+
+ protected Properties propertiesSource;
+
+ protected String configPropertyPrefix;
+
+ protected String placeholderPrefix = PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_PREFIX;
+
+ protected String placeholderSuffix = PlaceholderConfigurerSupport.DEFAULT_PLACEHOLDER_SUFFIX;
+
+ protected String valueSeparator = PlaceholderConfigurerSupport.DEFAULT_VALUE_SEPARATOR;
+
+ protected PropertyPlaceholderHelper placeholderHelper;
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void afterPropertiesSet()
+ {
+ PropertyCheck.mandatory(this, "propertiesSource", this.propertiesSource);
+ PropertyCheck.mandatory(this, "propertyPrefix", this.configPropertyPrefix);
+
+ this.placeholderHelper = new PropertyPlaceholderHelper(this.placeholderPrefix, this.placeholderSuffix, this.valueSeparator, true);
+ }
+
+ /**
+ * @param propertiesSource
+ * the propertiesSource to set
+ */
+ public void setPropertiesSource(final Properties propertiesSource)
+ {
+ this.propertiesSource = propertiesSource;
+ }
+
+ /**
+ * @param configPropertyPrefix
+ * the configPropertyPrefix to set
+ */
+ public void setConfigPropertyPrefix(final String configPropertyPrefix)
+ {
+ this.configPropertyPrefix = configPropertyPrefix;
+ }
+
+ /**
+ * @param placeholderPrefix
+ * the placeholderPrefix to set
+ */
+ public void setPlaceholderPrefix(final String placeholderPrefix)
+ {
+ this.placeholderPrefix = placeholderPrefix;
+ }
+
+ /**
+ * @param placeholderSuffix
+ * the placeholderSuffix to set
+ */
+ public void setPlaceholderSuffix(final String placeholderSuffix)
+ {
+ this.placeholderSuffix = placeholderSuffix;
+ }
+
+ /**
+ * @param valueSeparator
+ * the valueSeparator to set
+ */
+ public void setValueSeparator(final String valueSeparator)
+ {
+ this.valueSeparator = valueSeparator;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public AdapterConfig getObject() throws Exception
+ {
+ final AdapterConfig adapterConfig = new AdapterConfig();
+
+ CONFIG_NAMES.forEach(configFieldName -> {
+ final Class> valueType = VALUE_TYPE_BY_CONFIG_NAME.get(configFieldName);
+
+ Object value;
+ if (Map.class.isAssignableFrom(valueType))
+ {
+ value = this.loadConfigMap(configFieldName);
+ }
+ else
+ {
+ value = this.loadConfigValue(configFieldName, valueType);
+ }
+
+ if (value != null)
+ {
+ LOGGER.debug("Loaded {} as value of adapter config field {}", value, configFieldName);
+ try
+ {
+ final Method setter = SETTER_BY_CONFIG_NAME.get(configFieldName);
+ setter.invoke(adapterConfig, value);
+ }
+ catch (final IllegalAccessException | InvocationTargetException ex)
+ {
+ throw new AlfrescoRuntimeException("Error building adapter configuration", ex);
+ }
+ }
+ else
+ {
+ LOGGER.trace("No value specified for adapter config field {}", configFieldName);
+ }
+ });
+
+ return adapterConfig;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Class> getObjectType()
+ {
+ return AdapterConfig.class;
+ }
+
+ protected Object loadConfigValue(final String configFieldName, final Class> valueType)
+ {
+ Object effectiveValue;
+
+ final String propertyName = this.configPropertyPrefix + "." + configFieldName;
+ String value = this.propertiesSource.getProperty(propertyName);
+ if (value != null)
+ {
+ value = this.placeholderHelper.replacePlaceholders(value, this.propertiesSource);
+ }
+
+ if (value != null && !value.trim().isEmpty())
+ {
+ final String trimmedValue = value.trim();
+ if (Number.class.isAssignableFrom(valueType))
+ {
+ try
+ {
+ effectiveValue = valueType.getMethod("valueOf", String.class).invoke(null, trimmedValue);
+ }
+ catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException ex)
+ {
+ LOGGER.error(
+ "Number-based value type {} does not provide a publicly accessible, static valueOf to handle conversion of value {}",
+ valueType, trimmedValue);
+ throw new AlfrescoRuntimeException("Failed to convert configuration value " + trimmedValue, ex);
+ }
+ }
+ else if (Boolean.class.equals(valueType))
+ {
+ effectiveValue = Boolean.valueOf(trimmedValue);
+ }
+ else if (Character.class.equals(valueType))
+ {
+ if (trimmedValue.length() > 1)
+ {
+ throw new IllegalStateException("Value " + trimmedValue + " has more than one character");
+ }
+ effectiveValue = new Character(trimmedValue.charAt(0));
+ }
+ else if (String.class.equals(valueType))
+ {
+ effectiveValue = trimmedValue;
+ }
+ else
+ {
+ throw new UnsupportedOperationException("Unsupported value type " + valueType);
+ }
+ }
+ else
+ {
+ effectiveValue = null;
+ }
+
+ return effectiveValue;
+ }
+
+ protected Map loadConfigMap(final String configFieldName)
+ {
+ final Map configMap = new HashMap<>();
+ final String propertyPrefix = this.configPropertyPrefix + "." + configFieldName + ".";
+ this.propertiesSource.stringPropertyNames().stream().filter(p -> p.startsWith(propertyPrefix)).forEach(propertyName -> {
+ final String propertyConfigSuffix = propertyName.substring(propertyPrefix.length());
+ String value = this.propertiesSource.getProperty(propertyName);
+ value = this.placeholderHelper.replacePlaceholders(value, this.propertiesSource);
+
+ LOGGER.debug("Resolved value {} for map key {} of config field {}", value, propertyConfigSuffix, configFieldName);
+ if (value != null && !value.trim().isEmpty())
+ {
+ configMap.put(propertyConfigSuffix, value.trim());
+ }
+ });
+
+ return configMap.isEmpty() ? null : configMap;
+ }
+}
\ No newline at end of file
diff --git a/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java
new file mode 100644
index 0000000..943529a
--- /dev/null
+++ b/repository/src/main/java/de/acosix/alfresco/keycloak/repo/spring/KeycloakDeploymentBeanFactory.java
@@ -0,0 +1,119 @@
+/*
+ * 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.repo.spring;
+
+import java.util.concurrent.TimeUnit;
+
+import org.alfresco.util.PropertyCheck;
+import org.keycloak.adapters.HttpClientBuilder;
+import org.keycloak.adapters.KeycloakDeployment;
+import org.keycloak.adapters.KeycloakDeploymentBuilder;
+import org.keycloak.representations.adapters.config.AdapterConfig;
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.beans.factory.InitializingBean;
+
+/**
+ * @author Axel Faust
+ */
+public class KeycloakDeploymentBeanFactory implements FactoryBean, InitializingBean
+{
+
+ protected AdapterConfig adapterConfig;
+
+ protected int connectionTimeout;
+
+ protected int socketTimeout;
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public void afterPropertiesSet()
+ {
+ PropertyCheck.mandatory(this, "adapterConfig", this.adapterConfig);
+ }
+
+ /**
+ * @param adapterConfig
+ * the adapterConfig to set
+ */
+ public void setAdapterConfig(final AdapterConfig adapterConfig)
+ {
+ this.adapterConfig = adapterConfig;
+ }
+
+ /**
+ * @param connectionTimeout
+ * the connectionTimeout to set
+ */
+ public void setConnectionTimeout(final int connectionTimeout)
+ {
+ this.connectionTimeout = connectionTimeout;
+ }
+
+ /**
+ * @param socketTimeout
+ * the socketTimeout to set
+ */
+ public void setSocketTimeout(final int socketTimeout)
+ {
+ this.socketTimeout = socketTimeout;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public KeycloakDeployment getObject() throws Exception
+ {
+ final KeycloakDeployment keycloakDeployment = KeycloakDeploymentBuilder.build(this.adapterConfig);
+
+ HttpClientBuilder httpClientBuilder = new HttpClientBuilder();
+ if (this.connectionTimeout > 0)
+ {
+ httpClientBuilder = httpClientBuilder.establishConnectionTimeout(this.connectionTimeout, TimeUnit.MILLISECONDS);
+ }
+ if (this.socketTimeout > 0)
+ {
+ httpClientBuilder = httpClientBuilder.socketTimeout(this.socketTimeout, TimeUnit.MILLISECONDS);
+ }
+ keycloakDeployment.setClient(httpClientBuilder.build(this.adapterConfig));
+
+ return keycloakDeployment;
+ }
+
+ /**
+ *
+ * {@inheritDoc}
+ */
+ @Override
+ public boolean isSingleton()
+ {
+ // individual components may need to modify its configuration for their specific use case
+ // so this should not be a shared singleton
+ return false;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Class> getObjectType()
+ {
+ return KeycloakDeployment.class;
+ }
+}
\ No newline at end of file
diff --git a/repository/src/test/docker/Repository-Dockerfile b/repository/src/test/docker/Repository-Dockerfile
new file mode 100644
index 0000000..f533f5e
--- /dev/null
+++ b/repository/src/test/docker/Repository-Dockerfile
@@ -0,0 +1,11 @@
+FROM ${docker.tests.repositoryBaseImage}
+COPY maven ${docker.tests.repositoryWebappPath}
+
+# merge additions to alfresco-global.properties
+RUN echo "" >> ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \
+ && echo "#MergeGlobalProperties" >> ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \
+ && sed -i '/#MergeGlobalProperties/r ${docker.tests.repositoryWebappPath}/WEB-INF/classes/alfresco/extension/alfresco-global.addition.properties' ${docker.tests.repositoryWebappPath}/../../shared/classes/alfresco-global.properties \
+ && mv ${docker.tests.repositoryWebappPath}/WEB-INF/classes/alfresco/extension/entrypoint.sh $CATALINA_HOME/bin/ \
+ && chmod +x $CATALINA_HOME/bin/entrypoint.sh
+
+CMD ["entrypoint.sh", "catalina.sh run -security"]
\ No newline at end of file
diff --git a/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties b/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties
new file mode 100644
index 0000000..4d98028
--- /dev/null
+++ b/repository/src/test/docker/alfresco/extension/alfresco-global.addition.properties
@@ -0,0 +1,25 @@
+#
+# 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.
+
+# note: this file is not named alfresco-global.properties to not override the default file in the image
+# instead it relies on Dockerfile post-processing to merge with the default file
+
+authentication.chain=keycloak1:keycloak,alfrescoNtlm1:alfrescoNtlm
+
+keycloak.adapter.auth-server-url=http://${docker.tests.host.name}:${docker.tests.keycloakPort}/auth
+keycloak.adapter.realm=test
+keycloak.adapter.resource=alfresco
+keycloak.adapter.credentials.provider=secret
+keycloak.adapter.credentials.secret=6f70a28f-98cd-41ca-8f2f-368a8797d708
\ No newline at end of file
diff --git a/repository/src/test/docker/alfresco/extension/dev-log4j.properties b/repository/src/test/docker/alfresco/extension/dev-log4j.properties
new file mode 100644
index 0000000..2f921d2
--- /dev/null
+++ b/repository/src/test/docker/alfresco/extension/dev-log4j.properties
@@ -0,0 +1,25 @@
+#
+# 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.
+
+log4j.rootLogger=error, File
+
+log4j.appender.File=org.apache.log4j.DailyRollingFileAppender
+log4j.appender.File.File=\${catalina.base}/logs/alfresco.log
+log4j.appender.File.Append=true
+log4j.appender.File.DatePattern='.'yyyy-MM-dd
+log4j.appender.File.layout=org.apache.log4j.PatternLayout
+log4j.appender.File.layout.ConversionPattern=%d{ISO8601} %-5p [%c] [%t] %m%n
+
+log4j.logger.${project.artifactId}=DEBUG
\ No newline at end of file
diff --git a/repository/src/test/docker/alfresco/extension/entrypoint.sh b/repository/src/test/docker/alfresco/extension/entrypoint.sh
new file mode 100644
index 0000000..4d81e7a
--- /dev/null
+++ b/repository/src/test/docker/alfresco/extension/entrypoint.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+
+set -e
+
+ip=`hostname -I | awk '{print $1}'`
+hostip=`echo "${ip}" | sed -E 's/([0-9]+\.[0-9]+)\.0\.[0-9]+/\1.0.1/'`
+hostname="${DOCKER_HOST_NAME}"
+echo "${hostip} ${hostname}" >> /etc/hosts
+
+bash -c "$@"
\ No newline at end of file
diff --git a/repository/src/test/docker/repository-it.xml b/repository/src/test/docker/repository-it.xml
new file mode 100644
index 0000000..57016ee
--- /dev/null
+++ b/repository/src/test/docker/repository-it.xml
@@ -0,0 +1,100 @@
+
+
+
+ repository-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
+ **/*.properties
+
+ true
+ lf
+
+
+ ${project.basedir}/src/test/docker/alfresco
+ WEB-INF/classes/alfresco
+
+ *
+ **/*
+
+
+ *.js
+ **/*.js
+ *.ftl
+ **/*.ftl
+ *.keystore
+ **/*.keystore
+
+ true
+ lf
+
+
+ ${project.basedir}/src/test/docker/alfresco
+ WEB-INF/classes/alfresco
+
+ *.js
+ **/*.js
+ *.ftl
+ **/*.ftl
+ *.keystore
+ **/*.keystore
+
+
+
+
+
+ WEB-INF/lib
+
+
+ org.keycloak:keycloak-servlet-filter-adapter:*
+
+ compile
+
+
+ WEB-INF/lib
+
+
+
+
+ org.orderofthebee.support-tools:*
+ com.cronutils:*
+ net.time4j:*
+ 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:*
+
+ test
+
+
+
diff --git a/repository/src/test/docker/repository-logs/dummy.properties b/repository/src/test/docker/repository-logs/dummy.properties
new file mode 100644
index 0000000..5d13f33
--- /dev/null
+++ b/repository/src/test/docker/repository-logs/dummy.properties
@@ -0,0 +1 @@
+# only exists to ensure Maven creates path in project ./target
\ No newline at end of file
diff --git a/repository/src/test/docker/test-realm.json b/repository/src/test/docker/test-realm.json
new file mode 100644
index 0000000..fda30ca
--- /dev/null
+++ b/repository/src/test/docker/test-realm.json
@@ -0,0 +1,1342 @@
+{
+ "id": "test",
+ "realm": "test",
+ "users": [
+ {
+ "id": "mustermann",
+ "username": "mmustermann",
+ "enabled": true,
+ "email": "max.mustermann@muster.com",
+ "firstName": "Max",
+ "lastName": "Mustermann",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "mmustermann"
+ }
+ ],
+ "realmRoles": [
+ "user"
+ ],
+ "clientRoles": {
+ "account": [
+ "view-profile",
+ "manage-account"
+ ]
+ }
+ }
+ ],
+ "clients": [
+ {
+ "clientId": "alfresco",
+ "name": "Alfresco Repository",
+ "rootUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco",
+ "adminUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco/keycloak",
+ "baseUrl": "http://localhost:${docker.tests.repositoryPort}/alfresco",
+ "surrogateAuthRequired": false,
+ "enabled": true,
+ "clientAuthenticatorType": "client-secret",
+ "secret" : "6f70a28f-98cd-41ca-8f2f-368a8797d708",
+ "redirectUris": [
+ "http://localhost:${docker.tests.repositoryPort}/alfresco/*"
+ ],
+ "webOrigins": [
+ "http://localhost:${docker.tests.repositoryPort}"
+ ],
+ "notBefore": 0,
+ "bearerOnly": false,
+ "consentRequired": false,
+ "standardFlowEnabled": true,
+ "implicitFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": false,
+ "publicClient": false,
+ "frontchannelLogout": false,
+ "protocol": "openid-connect",
+ "attributes": {
+ "saml.assertion.signature": "false",
+ "saml.force.post.binding": "false",
+ "saml.multivalued.roles": "false",
+ "saml.encrypt": "false",
+ "saml.server.signature": "false",
+ "saml.server.signature.keyinfo.ext": "false",
+ "exclude.session.state.from.auth.response": "false",
+ "saml_force_name_id_format": "false",
+ "saml.client.signature": "false",
+ "tls.client.certificate.bound.access.tokens": "false",
+ "saml.authnstatement": "false",
+ "display.on.consent.screen": "false",
+ "saml.onetimeuse.condition": "false"
+ },
+ "authenticationFlowBindingOverrides": {
+
+ },
+ "fullScopeAllowed": true,
+ "nodeReRegistrationTimeout": -1,
+ "defaultClientScopes": [
+ "web-origins",
+ "role_list",
+ "roles",
+ "profile",
+ "email"
+ ],
+ "optionalClientScopes": [
+ "address",
+ "phone",
+ "offline_access",
+ "microprofile-jwt"
+ ],
+ "access": {
+ "view": true,
+ "configure": true,
+ "manage": true
+ }
+ }
+ ],
+ "notBefore": 0,
+ "revokeRefreshToken": false,
+ "refreshTokenMaxReuse": 0,
+ "accessTokenLifespan": 300,
+ "accessTokenLifespanForImplicitFlow": 900,
+ "ssoSessionIdleTimeout": 1800,
+ "ssoSessionMaxLifespan": 36000,
+ "ssoSessionIdleTimeoutRememberMe": 0,
+ "ssoSessionMaxLifespanRememberMe": 0,
+ "offlineSessionIdleTimeout": 2592000,
+ "offlineSessionMaxLifespanEnabled": false,
+ "offlineSessionMaxLifespan": 5184000,
+ "accessCodeLifespan": 60,
+ "accessCodeLifespanUserAction": 300,
+ "accessCodeLifespanLogin": 1800,
+ "actionTokenGeneratedByAdminLifespan": 43200,
+ "actionTokenGeneratedByUserLifespan": 300,
+ "enabled": true,
+ "sslRequired": "none",
+ "registrationAllowed": false,
+ "registrationEmailAsUsername": false,
+ "rememberMe": false,
+ "verifyEmail": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": false,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": false,
+ "permanentLockout": false,
+ "maxFailureWaitSeconds": 900,
+ "minimumQuickLoginWaitSeconds": 60,
+ "waitIncrementSeconds": 60,
+ "quickLoginCheckMilliSeconds": 1000,
+ "maxDeltaTimeSeconds": 43200,
+ "failureFactor": 30,
+ "defaultRoles": [
+ "offline_access",
+ "uma_authorization"
+ ],
+ "requiredCredentials": [
+ "password"
+ ],
+ "otpPolicyType": "totp",
+ "otpPolicyAlgorithm": "HmacSHA1",
+ "otpPolicyInitialCounter": 0,
+ "otpPolicyDigits": 6,
+ "otpPolicyLookAheadWindow": 1,
+ "otpPolicyPeriod": 30,
+ "otpSupportedApplications": [
+ "FreeOTP",
+ "Google Authenticator"
+ ],
+ "scopeMappings": [
+ {
+ "clientScope": "offline_access",
+ "roles": [
+ "offline_access"
+ ]
+ }
+ ],
+ "clientScopes": [
+ {
+ "id": "0ee41513-079a-4156-9eab-5709b18be2a6",
+ "name": "address",
+ "description": "OpenID Connect built-in scope: address",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${addressScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "1218801d-c159-4ddd-901c-2fc5afa06170",
+ "name": "address",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-address-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute.formatted": "formatted",
+ "user.attribute.country": "country",
+ "user.attribute.postal_code": "postal_code",
+ "userinfo.token.claim": "true",
+ "user.attribute.street": "street",
+ "id.token.claim": "true",
+ "user.attribute.region": "region",
+ "access.token.claim": "true",
+ "user.attribute.locality": "locality"
+ }
+ }
+ ]
+ },
+ {
+ "id": "bac1ffb3-92bf-481d-b6f8-8dec9bbe7291",
+ "name": "email",
+ "description": "OpenID Connect built-in scope: email",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${emailScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "4f28826f-46c6-4fe9-b499-611b66d9bc6f",
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "34521446-1260-416f-bbab-b13143cdf163",
+ "name": "email verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "emailVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email_verified",
+ "jsonType.label": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "id": "7a6e0dc7-5a3b-4c0e-9a6c-48c08d62b3e8",
+ "name": "microprofile-jwt",
+ "description": "Microprofile - JWT built-in scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "id": "a69c70ca-70c0-465b-8faf-fffd427f90d9",
+ "name": "groups",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "multivalued": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "foo",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "groups",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "5fadd383-2a4a-4970-a79e-9e9bc917476e",
+ "name": "upn",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "upn",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "id": "3080e57a-c685-473d-977e-268f85e4d62d",
+ "name": "offline_access",
+ "description": "OpenID Connect built-in scope: offline_access",
+ "protocol": "openid-connect",
+ "attributes": {
+ "consent.screen.text": "${offlineAccessScopeConsentText}",
+ "display.on.consent.screen": "true"
+ }
+ },
+ {
+ "id": "052c824f-caac-492c-9334-d68a1fe757e3",
+ "name": "phone",
+ "description": "OpenID Connect built-in scope: phone",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${phoneScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "850fe82e-ea0b-4cda-a0bc-dcc9d355c5a8",
+ "name": "phone number verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "phoneNumberVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "phone_number_verified",
+ "jsonType.label": "boolean"
+ }
+ },
+ {
+ "id": "63bf589d-e07e-4bae-b057-e7a7b8c72dc4",
+ "name": "phone number",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "phoneNumber",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "phone_number",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "id": "d9c5f7c3-d075-4400-bf27-970df51c4217",
+ "name": "profile",
+ "description": "OpenID Connect built-in scope: profile",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${profileScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "da5905e2-00ee-4ed0-ab7d-cdac7ead6206",
+ "name": "zoneinfo",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "zoneinfo",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "zoneinfo",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "ae7cf419-6550-449c-9df1-0386bb0ee652",
+ "name": "given name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "firstName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "given_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "ac275bed-9da2-4221-8b8b-f7b499cc2c4b",
+ "name": "full name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-full-name-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ },
+ {
+ "id": "3b88f7c9-62a1-42a2-b575-52181043e89c",
+ "name": "middle name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "middleName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "middle_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "90145df4-77c3-4c7a-8e15-59434c46bea1",
+ "name": "gender",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "gender",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "gender",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "5fea5786-6563-49c3-bdc1-26b125d5c8f0",
+ "name": "family name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "lastName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "family_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "f802a038-2b1a-4642-8758-2923a5d67d76",
+ "name": "nickname",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "nickname",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "nickname",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "7d050eba-1d77-480c-a12c-674d9201d790",
+ "name": "picture",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "picture",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "picture",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "6b54ca21-d615-4ef3-a703-0c0f20d9f393",
+ "name": "username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "cd3664d6-1888-464f-b144-3d1d6f332956",
+ "name": "profile",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "profile",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "profile",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "aafa8ba3-d0cd-4dd0-9238-73506a15b6b3",
+ "name": "locale",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "locale",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "locale",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "52d6c5f9-d1cf-4dbd-a8f3-9ce438ee3868",
+ "name": "birthdate",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "birthdate",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "birthdate",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "7dba042a-bb94-4307-a547-a02a0ab2239e",
+ "name": "website",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "website",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "website",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "id": "a6fb8c23-cfb4-4a5f-afcf-1bd360959c6b",
+ "name": "updated at",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "updatedAt",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "updated_at",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "id": "3ddbd9c1-92f1-4717-870e-4492df1d463a",
+ "name": "role_list",
+ "description": "SAML role list",
+ "protocol": "saml",
+ "attributes": {
+ "consent.screen.text": "${samlRoleListScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "id": "51899037-a3df-4924-bd73-81f07cfb3aa9",
+ "name": "role list",
+ "protocol": "saml",
+ "protocolMapper": "saml-role-list-mapper",
+ "consentRequired": false,
+ "config": {
+ "single": "false",
+ "attribute.nameformat": "Basic",
+ "attribute.name": "Role"
+ }
+ }
+ ]
+ },
+ {
+ "id": "4c15f94e-f490-4541-8c14-7b4734d6999b",
+ "name": "roles",
+ "description": "OpenID Connect scope for add user roles to the access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${rolesScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "id": "1908b63f-1be6-4f87-a947-30ee66721d05",
+ "name": "audience resolve",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-resolve-mapper",
+ "consentRequired": false,
+ "config": {
+
+ }
+ },
+ {
+ "id": "b830b103-f7af-4d78-8c44-53c6879544d6",
+ "name": "client roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "access.token.claim": "true",
+ "claim.name": "resource_access.${client_id}.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ },
+ {
+ "id": "c3e6e3d7-e462-4e25-9a7c-99c5eee63194",
+ "name": "realm roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "access.token.claim": "true",
+ "claim.name": "realm_access.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ }
+ ]
+ },
+ {
+ "id": "a6c18942-1bf9-4024-855e-65a5df06d810",
+ "name": "web-origins",
+ "description": "OpenID Connect scope for add allowed web origins to the access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false",
+ "consent.screen.text": ""
+ },
+ "protocolMappers": [
+ {
+ "id": "4652b835-1693-45ae-bad5-b61b534610de",
+ "name": "allowed web origins",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-allowed-origins-mapper",
+ "consentRequired": false,
+ "config": {
+
+ }
+ }
+ ]
+ }
+ ],
+ "defaultDefaultClientScopes": [
+ "role_list",
+ "roles",
+ "web-origins",
+ "email",
+ "profile"
+ ],
+ "defaultOptionalClientScopes": [
+ "phone",
+ "address",
+ "offline_access",
+ "microprofile-jwt"
+ ],
+ "browserSecurityHeaders": {
+ "contentSecurityPolicyReportOnly": "",
+ "xContentTypeOptions": "nosniff",
+ "xRobotsTag": "none",
+ "xFrameOptions": "SAMEORIGIN",
+ "xXSSProtection": "1; mode=block",
+ "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
+ "strictTransportSecurity": "max-age=31536000; includeSubDomains"
+ },
+ "smtpServer": {
+
+ },
+ "eventsEnabled": false,
+ "eventsListeners": [
+ "jboss-logging"
+ ],
+ "enabledEventTypes": [],
+ "adminEventsEnabled": false,
+ "adminEventsDetailsEnabled": false,
+ "components": {
+ "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
+ {
+ "id": "43a864c4-c4fa-4057-bf87-2ca409cde736",
+ "name": "Max Clients Limit",
+ "providerId": "max-clients",
+ "subType": "anonymous",
+ "subComponents": {
+
+ },
+ "config": {
+ "max-clients": [
+ "200"
+ ]
+ }
+ },
+ {
+ "id": "6d4772af-f62e-40e9-8370-44aebf0d694d",
+ "name": "Consent Required",
+ "providerId": "consent-required",
+ "subType": "anonymous",
+ "subComponents": {
+
+ },
+ "config": {
+
+ }
+ },
+ {
+ "id": "d474b02e-7d04-41c1-9c01-328221daa836",
+ "name": "Allowed Client Scopes",
+ "providerId": "allowed-client-templates",
+ "subType": "authenticated",
+ "subComponents": {
+
+ },
+ "config": {
+ "allow-default-scopes": [
+ "true"
+ ]
+ }
+ },
+ {
+ "id": "2f6ce962-4ad2-479b-87b2-35ea971039b1",
+ "name": "Allowed Protocol Mapper Types",
+ "providerId": "allowed-protocol-mappers",
+ "subType": "authenticated",
+ "subComponents": {
+
+ },
+ "config": {
+ "allowed-protocol-mapper-types": [
+ "oidc-address-mapper",
+ "oidc-sha256-pairwise-sub-mapper",
+ "oidc-usermodel-property-mapper",
+ "oidc-usermodel-attribute-mapper",
+ "saml-user-property-mapper",
+ "saml-user-attribute-mapper",
+ "saml-role-list-mapper",
+ "oidc-full-name-mapper"
+ ]
+ }
+ },
+ {
+ "id": "3f7ae6b8-63c8-4ddf-82d8-afebf153a41a",
+ "name": "Full Scope Disabled",
+ "providerId": "scope",
+ "subType": "anonymous",
+ "subComponents": {
+
+ },
+ "config": {
+
+ }
+ },
+ {
+ "id": "49326837-fe7d-47d6-85e7-5dd4028f0cd3",
+ "name": "Allowed Protocol Mapper Types",
+ "providerId": "allowed-protocol-mappers",
+ "subType": "anonymous",
+ "subComponents": {
+
+ },
+ "config": {
+ "allowed-protocol-mapper-types": [
+ "oidc-usermodel-property-mapper",
+ "saml-role-list-mapper",
+ "oidc-sha256-pairwise-sub-mapper",
+ "oidc-full-name-mapper",
+ "saml-user-property-mapper",
+ "oidc-usermodel-attribute-mapper",
+ "oidc-address-mapper",
+ "saml-user-attribute-mapper"
+ ]
+ }
+ },
+ {
+ "id": "c76f5e11-0576-4512-abf4-2d0b7cd5f355",
+ "name": "Trusted Hosts",
+ "providerId": "trusted-hosts",
+ "subType": "anonymous",
+ "subComponents": {
+
+ },
+ "config": {
+ "host-sending-registration-request-must-match": [
+ "true"
+ ],
+ "client-uris-must-match": [
+ "true"
+ ]
+ }
+ },
+ {
+ "id": "144efd74-bbbd-4176-8760-0e0819a61e5c",
+ "name": "Allowed Client Scopes",
+ "providerId": "allowed-client-templates",
+ "subType": "anonymous",
+ "subComponents": {
+
+ },
+ "config": {
+ "allow-default-scopes": [
+ "true"
+ ]
+ }
+ }
+ ],
+ "org.keycloak.keys.KeyProvider": [
+ {
+ "id": "005993b6-dcb3-4ebf-b19c-7287c740bb79",
+ "name": "rsa-generated",
+ "providerId": "rsa-generated",
+ "subComponents": {
+
+ },
+ "config": {
+ "priority": [
+ "100"
+ ]
+ }
+ },
+ {
+ "id": "865ea0f7-9411-4985-8516-a3a7112204f4",
+ "name": "aes-generated",
+ "providerId": "aes-generated",
+ "subComponents": {
+
+ },
+ "config": {
+ "priority": [
+ "100"
+ ]
+ }
+ },
+ {
+ "id": "7783eb59-57b1-491b-a570-934811ac95c3",
+ "name": "hmac-generated",
+ "providerId": "hmac-generated",
+ "subComponents": {
+
+ },
+ "config": {
+ "priority": [
+ "100"
+ ],
+ "algorithm": [
+ "HS256"
+ ]
+ }
+ }
+ ]
+ },
+ "internationalizationEnabled": false,
+ "supportedLocales": [],
+ "authenticationFlows": [
+ {
+ "id": "50eda798-0ed6-4fd1-9071-977ca22b032f",
+ "alias": "Handle Existing Account",
+ "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "idp-confirm-link",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "idp-email-verification",
+ "requirement": "ALTERNATIVE",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "requirement": "ALTERNATIVE",
+ "priority": 30,
+ "flowAlias": "Verify Existing Account by Re-authentication",
+ "userSetupAllowed": false,
+ "autheticatorFlow": true
+ }
+ ]
+ },
+ {
+ "id": "49354897-5c61-4aca-8b08-0601202abf49",
+ "alias": "Verify Existing Account by Re-authentication",
+ "description": "Reauthentication of existing account",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "idp-username-password-form",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "auth-otp-form",
+ "requirement": "OPTIONAL",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ },
+ {
+ "id": "f23bad00-b78e-4262-a025-5b2ef1d09dc4",
+ "alias": "browser",
+ "description": "browser based authentication",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "auth-cookie",
+ "requirement": "ALTERNATIVE",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "auth-spnego",
+ "requirement": "DISABLED",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "identity-provider-redirector",
+ "requirement": "ALTERNATIVE",
+ "priority": 25,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "requirement": "ALTERNATIVE",
+ "priority": 30,
+ "flowAlias": "forms",
+ "userSetupAllowed": false,
+ "autheticatorFlow": true
+ }
+ ]
+ },
+ {
+ "id": "15d11f2c-9f51-4e41-8ca7-020b7b9e9335",
+ "alias": "clients",
+ "description": "Base authentication for clients",
+ "providerId": "client-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "client-secret",
+ "requirement": "ALTERNATIVE",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "client-jwt",
+ "requirement": "ALTERNATIVE",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "client-secret-jwt",
+ "requirement": "ALTERNATIVE",
+ "priority": 30,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "client-x509",
+ "requirement": "ALTERNATIVE",
+ "priority": 40,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ },
+ {
+ "id": "6846e2ce-b014-4296-a111-6f68ca10c985",
+ "alias": "direct grant",
+ "description": "OpenID Connect Resource Owner Grant",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "direct-grant-validate-username",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "direct-grant-validate-password",
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "direct-grant-validate-otp",
+ "requirement": "OPTIONAL",
+ "priority": 30,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ },
+ {
+ "id": "9425e1d1-4533-413d-83c5-38c9657a355f",
+ "alias": "docker auth",
+ "description": "Used by Docker clients to authenticate against the IDP",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "docker-http-basic-authenticator",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ },
+ {
+ "id": "eb15d55b-a601-493c-9f49-069695624ada",
+ "alias": "first broker login",
+ "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticatorConfig": "review profile config",
+ "authenticator": "idp-review-profile",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticatorConfig": "create unique user config",
+ "authenticator": "idp-create-user-if-unique",
+ "requirement": "ALTERNATIVE",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "requirement": "ALTERNATIVE",
+ "priority": 30,
+ "flowAlias": "Handle Existing Account",
+ "userSetupAllowed": false,
+ "autheticatorFlow": true
+ }
+ ]
+ },
+ {
+ "id": "4fa685ea-65dd-4019-b3a3-a5f95b36e9f4",
+ "alias": "forms",
+ "description": "Username, password, otp and other auth forms.",
+ "providerId": "basic-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "auth-username-password-form",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "auth-otp-form",
+ "requirement": "OPTIONAL",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ },
+ {
+ "id": "8edbb281-f132-4625-bf15-ebc314dd0c5d",
+ "alias": "http challenge",
+ "description": "An authentication flow based on challenge-response HTTP Authentication Schemes",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "no-cookie-redirect",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "basic-auth",
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "basic-auth-otp",
+ "requirement": "DISABLED",
+ "priority": 30,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "auth-spnego",
+ "requirement": "DISABLED",
+ "priority": 40,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ },
+ {
+ "id": "88374fbb-95fa-4bd5-940a-1289a9a051f6",
+ "alias": "registration",
+ "description": "registration flow",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "registration-page-form",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "flowAlias": "registration form",
+ "userSetupAllowed": false,
+ "autheticatorFlow": true
+ }
+ ]
+ },
+ {
+ "id": "088ede2e-2dce-466d-b25f-62cc07c12bee",
+ "alias": "registration form",
+ "description": "registration form",
+ "providerId": "form-flow",
+ "topLevel": false,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "registration-user-creation",
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "registration-profile-action",
+ "requirement": "REQUIRED",
+ "priority": 40,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "registration-password-action",
+ "requirement": "REQUIRED",
+ "priority": 50,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "registration-recaptcha-action",
+ "requirement": "DISABLED",
+ "priority": 60,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ },
+ {
+ "id": "065e575d-20fe-45e1-ab7d-d54ef056db8e",
+ "alias": "reset credentials",
+ "description": "Reset credentials for a user if they forgot their password or something",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "reset-credentials-choose-user",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "reset-credential-email",
+ "requirement": "REQUIRED",
+ "priority": 20,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "reset-password",
+ "requirement": "REQUIRED",
+ "priority": 30,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ },
+ {
+ "authenticator": "reset-otp",
+ "requirement": "OPTIONAL",
+ "priority": 40,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ },
+ {
+ "id": "e1f9c5ba-5e45-4c51-a3cb-c10303b8c02e",
+ "alias": "saml ecp",
+ "description": "SAML ECP Profile Authentication Flow",
+ "providerId": "basic-flow",
+ "topLevel": true,
+ "builtIn": true,
+ "authenticationExecutions": [
+ {
+ "authenticator": "http-basic-authenticator",
+ "requirement": "REQUIRED",
+ "priority": 10,
+ "userSetupAllowed": false,
+ "autheticatorFlow": false
+ }
+ ]
+ }
+ ],
+ "authenticatorConfig": [
+ {
+ "id": "1593e111-7e5e-4ee2-b33d-f459834e4e3b",
+ "alias": "create unique user config",
+ "config": {
+ "require.password.update.after.registration": "false"
+ }
+ },
+ {
+ "id": "ae5f6b14-fb84-4320-90a4-da8c80c379bf",
+ "alias": "review profile config",
+ "config": {
+ "update.profile.on.first.login": "missing"
+ }
+ }
+ ],
+ "requiredActions": [
+ {
+ "alias": "CONFIGURE_TOTP",
+ "name": "Configure OTP",
+ "providerId": "CONFIGURE_TOTP",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 10,
+ "config": {
+
+ }
+ },
+ {
+ "alias": "terms_and_conditions",
+ "name": "Terms and Conditions",
+ "providerId": "terms_and_conditions",
+ "enabled": false,
+ "defaultAction": false,
+ "priority": 20,
+ "config": {
+
+ }
+ },
+ {
+ "alias": "UPDATE_PASSWORD",
+ "name": "Update Password",
+ "providerId": "UPDATE_PASSWORD",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 30,
+ "config": {
+
+ }
+ },
+ {
+ "alias": "UPDATE_PROFILE",
+ "name": "Update Profile",
+ "providerId": "UPDATE_PROFILE",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 40,
+ "config": {
+
+ }
+ },
+ {
+ "alias": "VERIFY_EMAIL",
+ "name": "Verify Email",
+ "providerId": "VERIFY_EMAIL",
+ "enabled": true,
+ "defaultAction": false,
+ "priority": 50,
+ "config": {
+
+ }
+ }
+ ],
+ "browserFlow": "browser",
+ "registrationFlow": "registration",
+ "directGrantFlow": "direct grant",
+ "resetCredentialsFlow": "reset credentials",
+ "clientAuthenticationFlow": "clients",
+ "dockerAuthenticationFlow": "docker auth",
+ "attributes": {
+ "_browser_header.xXSSProtection": "1; mode=block",
+ "_browser_header.xFrameOptions": "SAMEORIGIN",
+ "_browser_header.strictTransportSecurity": "max-age=31536000; includeSubDomains",
+ "permanentLockout": "false",
+ "quickLoginCheckMilliSeconds": "1000",
+ "_browser_header.xRobotsTag": "none",
+ "maxFailureWaitSeconds": "900",
+ "minimumQuickLoginWaitSeconds": "60",
+ "failureFactor": "30",
+ "actionTokenGeneratedByUserLifespan": "300",
+ "maxDeltaTimeSeconds": "43200",
+ "_browser_header.xContentTypeOptions": "nosniff",
+ "offlineSessionMaxLifespan": "5184000",
+ "actionTokenGeneratedByAdminLifespan": "43200",
+ "_browser_header.contentSecurityPolicyReportOnly": "",
+ "bruteForceProtected": "false",
+ "_browser_header.contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
+ "waitIncrementSeconds": "60",
+ "offlineSessionMaxLifespanEnabled": "false"
+ },
+ "keycloakVersion": "6.0.1",
+ "userManagedAccessAllowed": false
+}
\ No newline at end of file
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
index 8a8d1f5..ff396e7 100644
--- 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
@@ -306,11 +306,10 @@ public class KeycloakAdapterConfigElement extends BaseCustomConfigElement
{
for (final String configName : CONFIG_NAMES)
{
- final Method setter = SETTER_BY_CONFIG_NAME.get(configName);
-
final Object value = this.configValueByField.get(configName);
if (value != null)
{
+ final Method setter = SETTER_BY_CONFIG_NAME.get(configName);
setter.invoke(config, value);
}
}
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
index 2a287db..df043ab 100644
--- 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
@@ -84,7 +84,7 @@ public class KeycloakAdapterConfigElementReader implements ConfigElementReader
configElement.setFieldValue(subElementName,
valueType.getMethod("valueOf", String.class).invoke(null, textTrim));
}
- catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException ex)
+ catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException ex)
{
LOGGER.error(
"Number-based value type {} does not provide a publicly accessible, static valueOf to handle conversion of value {}",
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
index f16dac6..6cc374f 100644
--- 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
@@ -19,6 +19,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -549,7 +550,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
protected void prepareLoginFormEnhancement(final ServletContext context, final HttpServletRequest req, final HttpServletResponse res,
final FilterRequestAuthenticator authenticator)
{
- final RedirectCaptureServletHttpFacade captureFacade = new RedirectCaptureServletHttpFacade(req);
+ final ResponseHeaderCookieCaptureServletHttpFacade captureFacade = new ResponseHeaderCookieCaptureServletHttpFacade(req);
authenticator.getChallenge().challenge(captureFacade);
@@ -603,7 +604,7 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
};
- final RedirectCaptureServletHttpFacade captureFacade = new RedirectCaptureServletHttpFacade(wrappedReq);
+ final ResponseHeaderCookieCaptureServletHttpFacade captureFacade = new ResponseHeaderCookieCaptureServletHttpFacade(wrappedReq);
final OIDCFilterSessionStore tokenStore = new OIDCFilterSessionStore(req, captureFacade,
bodyBufferLimit != null ? bodyBufferLimit.intValue() : DEFAULT_BODY_BUFFER_LIMIT, this.keycloakDeployment, null);
@@ -786,6 +787,8 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
{
boolean skip = false;
+ final String authHeader = req.getHeader(HEADER_AUTHORIZATION);
+
final String servletPath = req.getServletPath();
final String pathInfo = req.getPathInfo();
final String servletRequestUri = servletPath + (pathInfo != null ? pathInfo : "");
@@ -824,24 +827,25 @@ public class KeycloakAuthenticationFilter implements DependencyInjectedFilter, I
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 "))
+ else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("bearer "))
{
LOGGER.debug("Explicitly not skipping doFilter as Bearer authorization header is present");
}
- else if (req.getHeader(HEADER_AUTHORIZATION) != null)
+ else if (authHeader != null && authHeader.toLowerCase(Locale.ENGLISH).startsWith("basic "))
{
- LOGGER.debug("Skipping doFilter as non-OIDC authorization header is present");
+ LOGGER.debug("Explicitly not skipping doFilter as Basic authorization header is present");
+ }
+ else if (authHeader != null)
+ {
+ LOGGER.debug("Skipping doFilter as non-OIDC / non-Basic authorization header is present");
skip = true;
}
- else if (req.getHeader(HEADER_AUTHORIZATION) == null && (currentSession != null && AuthenticationUtil.isAuthenticated(req)))
+ else if (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);
+ skip = this.validateAndRefreshKeycloakAuthentication(req, res, AuthenticationUtil.getUserId(req), keycloakAccount);
}
else
{
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/ResponseHeaderCookieCaptureServletHttpFacade.java
similarity index 87%
rename from share/src/main/java/de/acosix/alfresco/keycloak/share/web/RedirectCaptureServletHttpFacade.java
rename to share/src/main/java/de/acosix/alfresco/keycloak/share/web/ResponseHeaderCookieCaptureServletHttpFacade.java
index a25531e..80cbde7 100644
--- a/share/src/main/java/de/acosix/alfresco/keycloak/share/web/RedirectCaptureServletHttpFacade.java
+++ b/share/src/main/java/de/acosix/alfresco/keycloak/share/web/ResponseHeaderCookieCaptureServletHttpFacade.java
@@ -36,7 +36,7 @@ import org.keycloak.adapters.spi.HttpFacade;
*
* @author Axel Faust
*/
-public class RedirectCaptureServletHttpFacade extends ServletHttpFacade
+public class ResponseHeaderCookieCaptureServletHttpFacade extends ServletHttpFacade
{
protected final Map, javax.servlet.http.Cookie> cookies = new HashMap<>();
@@ -49,7 +49,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade
* @param request
* the servlet request to facade
*/
- public RedirectCaptureServletHttpFacade(final HttpServletRequest request)
+ public ResponseHeaderCookieCaptureServletHttpFacade(final HttpServletRequest request)
{
super(request, null);
}
@@ -106,7 +106,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade
@Override
public void addHeader(final String name, final String value)
{
- RedirectCaptureServletHttpFacade.this.headers.computeIfAbsent(name, key -> new ArrayList<>()).add(value);
+ ResponseHeaderCookieCaptureServletHttpFacade.this.headers.computeIfAbsent(name, key -> new ArrayList<>()).add(value);
}
/**
@@ -116,7 +116,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade
@Override
public void setHeader(final String name, final String value)
{
- RedirectCaptureServletHttpFacade.this.headers.put(name, new ArrayList<>(Collections.singleton(value)));
+ ResponseHeaderCookieCaptureServletHttpFacade.this.headers.put(name, new ArrayList<>(Collections.singleton(value)));
}
/**
@@ -126,7 +126,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade
@Override
public void resetCookie(final String name, final String path)
{
- RedirectCaptureServletHttpFacade.this.cookies.remove(new Pair<>(name, path));
+ ResponseHeaderCookieCaptureServletHttpFacade.this.cookies.remove(new Pair<>(name, path));
}
/**
@@ -146,7 +146,7 @@ public class RedirectCaptureServletHttpFacade extends ServletHttpFacade
cookie.setMaxAge(maxAge);
cookie.setSecure(secure);
cookie.setHttpOnly(httpOnly);
- RedirectCaptureServletHttpFacade.this.cookies.put(new Pair<>(name, path), cookie);
+ ResponseHeaderCookieCaptureServletHttpFacade.this.cookies.put(new Pair<>(name, path), cookie);
}
/**