diff --git a/docs/CHANGES.md b/docs/CHANGES.md index 4dfe4c79146..49413db565a 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -1,5 +1,9 @@ ## Next Release +## v5.15.2 + +- new external storage option (Redis) for sessions; see `README.md` under new uPortal-session submodule + ## v5.15.0 - new cache, org.apereo.portal.i18n.RDBMLocaleStore.userLocales in `ehcache.xml`/`ehcache-no-jgroups.xml` diff --git a/gradle.properties b/gradle.properties index bc9509b7a3b..37f983f0ed3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -97,6 +97,7 @@ plutoVersion=2.1.0-M3 resourceServerVersion=1.3.1 slf4jVersion=1.7.36 springVersion=4.3.30.RELEASE +springSessionVersion=1.3.5.RELEASE spockVersion=2.1-groovy-3.0 springfoxSwaggerVersion=2.9.2 springLdapVersion=2.3.4.RELEASE diff --git a/settings.gradle b/settings.gradle index 279a84a7d95..64ae3b90527 100644 --- a/settings.gradle +++ b/settings.gradle @@ -10,6 +10,7 @@ include 'uPortal-persondir' include 'uPortal-portlets' include 'uPortal-rendering' include 'uPortal-rdbm' +include 'uPortal-session' include 'uPortal-spring' include 'uPortal-tenants' include 'uPortal-tools' diff --git a/uPortal-content/uPortal-content-portlet/src/main/java/org/apereo/portal/portlet/registry/SubscribeKey.java b/uPortal-content/uPortal-content-portlet/src/main/java/org/apereo/portal/portlet/registry/SubscribeKey.java index 74816ec1608..8790a7560f9 100644 --- a/uPortal-content/uPortal-content-portlet/src/main/java/org/apereo/portal/portlet/registry/SubscribeKey.java +++ b/uPortal-content/uPortal-content-portlet/src/main/java/org/apereo/portal/portlet/registry/SubscribeKey.java @@ -14,7 +14,12 @@ */ package org.apereo.portal.portlet.registry; -final class SubscribeKey { +import java.io.Serializable; + +final class SubscribeKey implements Serializable { + + private static final long serialVersionUID = 1L; + private final int userId; private final String layoutNodeId; diff --git a/uPortal-security/uPortal-security-mvc/src/main/java/org/apereo/portal/url/RequireValidSessionFilter.java b/uPortal-security/uPortal-security-mvc/src/main/java/org/apereo/portal/url/RequireValidSessionFilter.java index c611f4fb0a8..30ad3a386aa 100644 --- a/uPortal-security/uPortal-security-mvc/src/main/java/org/apereo/portal/url/RequireValidSessionFilter.java +++ b/uPortal-security/uPortal-security-mvc/src/main/java/org/apereo/portal/url/RequireValidSessionFilter.java @@ -40,8 +40,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) { // (1) You have a valid session (original method) final HttpSession session = request.getSession(false); - if (session != null && !session.isNew()) { - // Session exists and is not new, don't bother filtering + if (session != null) { + // Session exists, don't bother filtering log.debug("User {} has a session: {}", request.getRemoteUser(), session.getId()); log.debug("Max inactive interval: {}", session.getMaxInactiveInterval()); if (log.isDebugEnabled()) { diff --git a/uPortal-session/README.md b/uPortal-session/README.md new file mode 100644 index 00000000000..c2c054c7742 --- /dev/null +++ b/uPortal-session/README.md @@ -0,0 +1,69 @@ +# uPortal Session + +The purpose of this submodule is to allow Spring Session to be optionally used +in order to provide support for web session clustering, replication, and +failover. + +Spring Session provides the capability to store web sessions external to the +Servlet container (Tomcat). While Spring Session supports several storage +options, uPortal Session currently only supports using Redis. + +## Enabling + +The use of Spring Session is optional. By default, it is disabled. In order +to enable it, use the following environment variable or system property with +the value of 'redis': + +- environment variable: ORG_APEREO_PORTAL_SESSION_STORETYPE +- system property: org.apereo.portal.session.storetype + +Note that an application property was not used because at the time of servlet +context initialization, the application properties are not available for use. + +## Redis Connection Config + +### Mode + +There are three modes supported for connecting to Redis: +- cluster +- sentinel +- standalone + +The 'org.apero.portal.session.redis.mode' property should be set to one of these values. +If the property is not found, then the value of 'standalone' will be used by +default. + +#### Cluster + +When using cluster mode, the following properties should be used: + +- org.apereo.portal.session.redis.cluster.nodes +- org.apereo.portal.session.redis.cluster.maxredirects + +#### Sentinel + +When using sentinel mode, the following properties should be used: + +- org.apereo.portal.session.redis.sentinel.master +- org.apereo.portal.session.redis.sentinel.nodes + +#### Standalone + +When using standalone mode, the following default values will be used: + +- host: 127.0.0.1 +- port: 6379 + +These can be overwritten by using the following properties: + +- org.apereo.portal.session.redis.host +- org.apereo.portal.session.redis.port + +#### Additional Config + +The following properties can optionally be used to additionally configure the +Redis connection: + +- org.apereo.portal.session.redis.timeout +- org.apereo.portal.session.redis.password +- org.apereo.portal.session.redis.database diff --git a/uPortal-session/build.gradle b/uPortal-session/build.gradle new file mode 100644 index 00000000000..0f25ab4348d --- /dev/null +++ b/uPortal-session/build.gradle @@ -0,0 +1,14 @@ +description = "Apereo uPortal Session" + +dependencies { + implementation "org.springframework:spring-web:${springVersion}" + implementation "org.springframework.session:spring-session-data-redis:${springSessionVersion}" + + compileOnly "${servletApiDependency}" + + testImplementation "${servletApiDependency}" + testImplementation "com.github.stefanbirkner:system-rules:1.19.0" + + testRuntimeOnly "org.slf4j:jcl-over-slf4j:${slf4jVersion}" + testRuntimeOnly "org.slf4j:slf4j-api:${slf4jVersion}" +} diff --git a/uPortal-session/src/main/java/org/apereo/portal/session/PortalSessionConstants.java b/uPortal-session/src/main/java/org/apereo/portal/session/PortalSessionConstants.java new file mode 100644 index 00000000000..5c0575fb243 --- /dev/null +++ b/uPortal-session/src/main/java/org/apereo/portal/session/PortalSessionConstants.java @@ -0,0 +1,29 @@ +/** + * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright ownership. Apereo + * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use + * this file except in compliance with the License. You may obtain a copy of the License at the + * following location: + * + *
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 org.apereo.portal.session; + +public class PortalSessionConstants { + + private PortalSessionConstants() {} + + public static final String REDIS_STORE_TYPE = "redis"; + public static final String REDIS_STANDALONE_MODE = "standalone"; + public static final String REDIS_SENTINEL_MODE = "sentinel"; + public static final String REDIS_CLUSTER_MODE = "cluster"; + public static final String SESSION_STORE_TYPE_ENV_PROPERTY_NAME = + "ORG_APEREO_PORTAL_SESSION_STORETYPE"; + public static final String SESSION_STORE_TYPE_SYSTEM_PROPERTY_NAME = + "org.apereo.portal.session.storetype"; +} diff --git a/uPortal-session/src/main/java/org/apereo/portal/session/redis/RedisSessionConfig.java b/uPortal-session/src/main/java/org/apereo/portal/session/redis/RedisSessionConfig.java new file mode 100644 index 00000000000..c2a2d3f56f2 --- /dev/null +++ b/uPortal-session/src/main/java/org/apereo/portal/session/redis/RedisSessionConfig.java @@ -0,0 +1,127 @@ +/** + * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information regarding copyright ownership. Apereo + * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use + * this file except in compliance with the License. You may obtain a copy of the License at the + * following location: + * + *
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 org.apereo.portal.session.redis;
+
+import static org.apereo.portal.session.PortalSessionConstants.REDIS_CLUSTER_MODE;
+import static org.apereo.portal.session.PortalSessionConstants.REDIS_SENTINEL_MODE;
+import static org.apereo.portal.session.PortalSessionConstants.REDIS_STANDALONE_MODE;
+
+import java.util.Arrays;
+import java.util.List;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisClusterConfiguration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.RedisSentinelConfiguration;
+import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
+import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
+
+@Configuration
+@Conditional(SpringSessionRedisEnabledCondition.class)
+@EnableRedisHttpSession
+public class RedisSessionConfig {
+
+ @Value("${org.apereo.portal.session.redis.mode:standalone}")
+ private String redisMode;
+
+ @Value("${org.apereo.portal.session.redis.host:#{null}}")
+ private String redisHost;
+
+ @Value("${org.apereo.portal.session.redis.port:#{null}}")
+ private Integer redisPort;
+
+ @Value("${org.apereo.portal.session.redis.cluster.nodes:#{null}}")
+ private String redisClusterNodes;
+
+ @Value("${org.apereo.portal.session.redis.cluster.maxredirects:#{null}}")
+ private Integer redisClusterMaxRedirects;
+
+ @Value("${org.apereo.portal.session.redis.sentinel.master:#{null}}")
+ private String redisSentinelMaster;
+
+ @Value("${org.apereo.portal.session.redis.sentinel.nodes:#{null}}")
+ private String redisSentinelNodes;
+
+ @Value("${org.apereo.portal.session.redis.database:#{null}}")
+ private Integer redisDatabase;
+
+ @Value("${org.apereo.portal.session.redis.password:#{null}}")
+ private String redisPassword;
+
+ @Value("${org.apereo.portal.session.redis.timeout:#{null}}")
+ private Integer redisTimeout;
+
+ @Bean
+ public RedisConnectionFactory redisConnectionFactory() {
+ JedisConnectionFactory result;
+ if (REDIS_CLUSTER_MODE.equalsIgnoreCase(this.redisMode)) {
+ result = this.createBaseClusterConnectionFactory();
+ } else if (REDIS_SENTINEL_MODE.equalsIgnoreCase(this.redisMode)) {
+ result = this.createBaseSentinelConnectionFactory();
+ } else if (REDIS_STANDALONE_MODE.equalsIgnoreCase(this.redisMode)) {
+ result = this.createBaseStandaloneConnectionFactory();
+ } else {
+ throw new IllegalArgumentException(
+ "Invalid value for org.apereo.portal.session.redis.mode: " + this.redisMode);
+ }
+ this.setAdditionalProperties(result);
+ return result;
+ }
+
+ private JedisConnectionFactory createBaseClusterConnectionFactory() {
+ List 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 org.apereo.portal.session.redis;
+
+import static org.apereo.portal.session.PortalSessionConstants.REDIS_STORE_TYPE;
+import static org.apereo.portal.session.PortalSessionConstants.SESSION_STORE_TYPE_ENV_PROPERTY_NAME;
+import static org.apereo.portal.session.PortalSessionConstants.SESSION_STORE_TYPE_SYSTEM_PROPERTY_NAME;
+
+import org.springframework.session.web.context.AbstractHttpSessionApplicationInitializer;
+
+/**
+ * This class is needed to enable Spring Session Redis support in uPortal. It registers the filter
+ * that is needed by Spring Session to manage the session with Redis. It also ensures that the
+ * filter is only registered if the session store-type is configured for Redis. The filter could
+ * have instead been added to web.xml, but that would not have allowed for the feature to be
+ * enabled/disabled via configuration. Note that the application properties are not available during
+ * initialization, and therefore we instead check for an environment variable or system property.
+ */
+public class RedisSessionInitializer extends AbstractHttpSessionApplicationInitializer {
+
+ public RedisSessionInitializer() {
+ // MUST pass null here to avoid having Spring Session create a root WebApplicationContext
+ // that does not work with the current uPortal setup.
+ super((Class>[]) null);
+ }
+
+ @Override
+ public void onStartup(javax.servlet.ServletContext servletContext)
+ throws javax.servlet.ServletException {
+ if (REDIS_STORE_TYPE.equals(this.getStoreTypeConfiguredValue())) {
+ super.onStartup(servletContext);
+ }
+ }
+
+ private String getStoreTypeConfiguredValue() {
+ String result = System.getProperty(SESSION_STORE_TYPE_SYSTEM_PROPERTY_NAME);
+ if (result == null) {
+ result = System.getenv(SESSION_STORE_TYPE_ENV_PROPERTY_NAME);
+ }
+ return result;
+ }
+}
diff --git a/uPortal-session/src/main/java/org/apereo/portal/session/redis/SpringSessionRedisEnabledCondition.java b/uPortal-session/src/main/java/org/apereo/portal/session/redis/SpringSessionRedisEnabledCondition.java
new file mode 100644
index 00000000000..152ff50ba30
--- /dev/null
+++ b/uPortal-session/src/main/java/org/apereo/portal/session/redis/SpringSessionRedisEnabledCondition.java
@@ -0,0 +1,43 @@
+/**
+ * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information regarding copyright ownership. Apereo
+ * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
+ * this file except in compliance with the License. You may obtain a copy of the License at the
+ * following location:
+ *
+ * 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 org.apereo.portal.session.redis;
+
+import static org.apereo.portal.session.PortalSessionConstants.REDIS_STORE_TYPE;
+import static org.apereo.portal.session.PortalSessionConstants.SESSION_STORE_TYPE_ENV_PROPERTY_NAME;
+import static org.apereo.portal.session.PortalSessionConstants.SESSION_STORE_TYPE_SYSTEM_PROPERTY_NAME;
+
+import org.springframework.context.annotation.Condition;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.core.type.AnnotatedTypeMetadata;
+
+public class SpringSessionRedisEnabledCondition implements Condition {
+
+ @Override
+ public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
+ return REDIS_STORE_TYPE.equals(this.getSessionStoreTypeValue(context));
+ }
+
+ private String getSessionStoreTypeValue(ConditionContext context) {
+ String result =
+ context.getEnvironment()
+ .getProperty(SESSION_STORE_TYPE_SYSTEM_PROPERTY_NAME, String.class, null);
+ if (result == null) {
+ result =
+ context.getEnvironment()
+ .getProperty(SESSION_STORE_TYPE_ENV_PROPERTY_NAME, String.class, null);
+ }
+ return result;
+ }
+}
diff --git a/uPortal-session/src/test/java/org/apereo/portal/session/redis/RedisSessionInitializerTest.java b/uPortal-session/src/test/java/org/apereo/portal/session/redis/RedisSessionInitializerTest.java
new file mode 100644
index 00000000000..951b22a4875
--- /dev/null
+++ b/uPortal-session/src/test/java/org/apereo/portal/session/redis/RedisSessionInitializerTest.java
@@ -0,0 +1,106 @@
+/**
+ * Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information regarding copyright ownership. Apereo
+ * licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
+ * this file except in compliance with the License. You may obtain a copy of the License at the
+ * following location:
+ *
+ * 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 org.apereo.portal.session.redis;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterRegistration.Dynamic;
+import javax.servlet.ServletContext;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.contrib.java.lang.system.EnvironmentVariables;
+import org.junit.contrib.java.lang.system.RestoreSystemProperties;
+import org.junit.rules.TestRule;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+public class RedisSessionInitializerTest {
+
+ private static final String EXPECTED_FILTER_NAME = "springSessionRepositoryFilter";
+
+ private RedisSessionInitializer redisSessionInitializer;
+
+ @Mock private ServletContext servletContext;
+ @Mock private Dynamic FilterRegistration;
+
+ @Rule public final EnvironmentVariables environmentVariables = new EnvironmentVariables();
+ @Rule public final TestRule restoreSystemProperties = new RestoreSystemProperties();
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.openMocks(this);
+ when(this.servletContext.addFilter(anyString(), any(Filter.class)))
+ .thenReturn(this.FilterRegistration);
+ this.redisSessionInitializer = new RedisSessionInitializer();
+ }
+
+ @Test
+ public void testOnStartupAddsFilterWhenStoreTypeSystemPropertySetToRedis() throws Exception {
+ System.setProperty("org.apereo.portal.session.storetype", "redis");
+ this.redisSessionInitializer.onStartup(this.servletContext);
+ verify(this.servletContext, times(1))
+ .addFilter(eq(EXPECTED_FILTER_NAME), any(Filter.class));
+ }
+
+ @Test
+ public void
+ testOnStartupDoesNotAddFilterWhenStoreTypeEnvironmentVariableAndSystemPropertyNotFound()
+ throws Exception {
+ this.redisSessionInitializer.onStartup(this.servletContext);
+ verify(this.servletContext, never()).addFilter(anyString(), any(Filter.class));
+ }
+
+ @Test
+ public void testOnStartupDoesNotAddFilterWhenStoreTypeSystemPropertyValueIsNotRedis()
+ throws Exception {
+ System.setProperty("org.apereo.portal.session.storetype", "rdbms");
+ this.redisSessionInitializer.onStartup(this.servletContext);
+ verify(this.servletContext, never()).addFilter(anyString(), any(Filter.class));
+ }
+
+ @Test
+ public void testOnStartupAddsFilterWhenStoreTypeEnvironmentVariableSetToRedis()
+ throws Exception {
+ this.environmentVariables.set("ORG_APEREO_PORTAL_SESSION_STORETYPE", "redis");
+ this.redisSessionInitializer.onStartup(this.servletContext);
+ verify(this.servletContext, times(1))
+ .addFilter(eq(EXPECTED_FILTER_NAME), any(Filter.class));
+ }
+
+ @Test
+ public void testOnStartupDoesNotAddFilterWhenStoreTypeEnvironmentVariableValueIsNotRedis()
+ throws Exception {
+ this.environmentVariables.set("ORG_APEREO_PORTAL_SESSION_STORETYPE", "foo");
+ this.redisSessionInitializer.onStartup(this.servletContext);
+ verify(this.servletContext, never()).addFilter(anyString(), any(Filter.class));
+ }
+
+ @Test
+ public void testOnStartupHasSystemPropertyTakePresidenceOverEnvironmentVariable()
+ throws Exception {
+ this.environmentVariables.set("ORG_APEREO_PORTAL_SESSION_STORETYPE", "redis");
+ System.setProperty("org.apereo.portal.session.storetype", "foo");
+ this.redisSessionInitializer.onStartup(this.servletContext);
+ verify(this.servletContext, never()).addFilter(anyString(), any(Filter.class));
+ }
+}
diff --git a/uPortal-webapp/build.gradle b/uPortal-webapp/build.gradle
index ead64d2e0ca..7a4308b272c 100644
--- a/uPortal-webapp/build.gradle
+++ b/uPortal-webapp/build.gradle
@@ -39,6 +39,7 @@ dependencies {
api project(':uPortal-security:uPortal-security-authn')
api project(':uPortal-security:uPortal-security-xslt')
api project(':uPortal-security:uPortal-security-filters')
+ api project(':uPortal-session')
api project(':uPortal-soffit:uPortal-soffit-connector')
api project(':uPortal-utils:uPortal-utils-jmx')
api project(':uPortal-utils:uPortal-utils-url')
diff --git a/uPortal-webapp/src/main/java/org/apereo/portal/PortalWebAppInitializer.java b/uPortal-webapp/src/main/java/org/apereo/portal/PortalWebAppInitializer.java
new file mode 100644
index 00000000000..5053c795d90
--- /dev/null
+++ b/uPortal-webapp/src/main/java/org/apereo/portal/PortalWebAppInitializer.java
@@ -0,0 +1,41 @@
+package org.apereo.portal;
+
+import org.springframework.web.context.AbstractContextLoaderInitializer;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.context.support.XmlWebApplicationContext;
+
+/**
+ * This class does the following: 1. creates the root application context using the specified config
+ * locations 2. initializes the context loader
+ *
+ * This replaces the the following, which were previously defined in web.xml file.
+ *
+ * {@code
+ *
+ *
+ * This new approach allows us to dynamically update the servlet context programatically with
+ * Spring, which was needed in order support Spring Session handling as a feature that could be
+ * enabled/disabled with configuration.
+ */
+public class PortalWebAppInitializer extends AbstractContextLoaderInitializer {
+
+ @Override
+ protected WebApplicationContext createRootApplicationContext() {
+ XmlWebApplicationContext context = new XmlWebApplicationContext();
+ context.setConfigLocation(
+ "classpath:/properties/contexts/*.xml,classpath:/properties/contextOverrides/*.xml");
+ return context;
+ }
+}
diff --git a/uPortal-webapp/src/main/resources/properties/contexts/applicationContext.xml b/uPortal-webapp/src/main/resources/properties/contexts/applicationContext.xml
index 1e418b66b06..a90eb9f6064 100644
--- a/uPortal-webapp/src/main/resources/properties/contexts/applicationContext.xml
+++ b/uPortal-webapp/src/main/resources/properties/contexts/applicationContext.xml
@@ -53,6 +53,7 @@