Skip to content

Commit

Permalink
feat: add new 'uPortal-session' submodule which adds support for sess…
Browse files Browse the repository at this point in the history
…ion clustering/replication/failover with Redis
  • Loading branch information
groybal committed Nov 4, 2023
1 parent 2ababaa commit e670a19
Show file tree
Hide file tree
Showing 17 changed files with 556 additions and 15 deletions.
4 changes: 4 additions & 0 deletions docs/CHANGES.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
69 changes: 69 additions & 0 deletions uPortal-session/README.md
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions uPortal-session/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
description = "Apereo uPortal Session"

dependencies {
implementation "org.springframework:spring-web:${springVersion}"
implementation "org.springframework.session:spring-session-data-redis:${springSessionVersion}"

compileOnly "${servletApiDependency}"

testImplementation "${servletApiDependency}"

testRuntimeOnly "org.slf4j:jcl-over-slf4j:${slf4jVersion}"
testRuntimeOnly "org.slf4j:slf4j-api:${slf4jVersion}"
}
Original file line number Diff line number Diff line change
@@ -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:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT 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";
}
Original file line number Diff line number Diff line change
@@ -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:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT 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<String> nodesList = Arrays.asList(this.redisClusterNodes.split(","));
RedisClusterConfiguration clusterConfig = new RedisClusterConfiguration(nodesList);
if (this.redisClusterMaxRedirects != null) {
clusterConfig.setMaxRedirects(this.redisClusterMaxRedirects);
}
return new JedisConnectionFactory(clusterConfig);
}

private JedisConnectionFactory createBaseSentinelConnectionFactory() {
RedisSentinelConfiguration sentinelConfig = new RedisSentinelConfiguration();
sentinelConfig.master(this.redisSentinelMaster);
String[] nodesArray = this.redisSentinelNodes.split(",");
for (String node : nodesArray) {
String[] hostAndPort = node.split(":");
sentinelConfig.sentinel(hostAndPort[0], Integer.parseInt(hostAndPort[1]));
}
return new JedisConnectionFactory(sentinelConfig);
}

private JedisConnectionFactory createBaseStandaloneConnectionFactory() {
final JedisConnectionFactory result = new JedisConnectionFactory();
if (this.redisHost != null) {
result.setHostName(this.redisHost);
}
if (this.redisPort != null) {
result.setPort(this.redisPort);
}
return result;
}

private void setAdditionalProperties(JedisConnectionFactory factory) {
if (this.redisDatabase != null) {
factory.setDatabase(this.redisDatabase);
}
if (this.redisPassword != null) {
factory.setPassword(this.redisPassword);
}
if (this.redisTimeout != null) {
factory.setTimeout(this.redisTimeout);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* 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:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT 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;
}
}
Original file line number Diff line number Diff line change
@@ -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:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT 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;
}
}
Loading

0 comments on commit e670a19

Please sign in to comment.