Add Hazelcast

Fixes gh-276
This commit is contained in:
Tommy Ludwig
2015-08-23 23:57:08 +09:00
committed by Rob Winch
parent a48864bf20
commit d1c00c6080
16 changed files with 889 additions and 213 deletions

View File

@@ -32,6 +32,7 @@ dependencies {
'org.mockito:mockito-core:1.9.5',
"org.springframework:spring-test:$springVersion",
'org.easytesting:fest-assert:1.4',
"com.hazelcast:hazelcast:$hazelcastVersion",
"redis.clients:jedis:2.4.1",
"javax.servlet:javax.servlet-api:$servletApiVersion"
}
@@ -47,6 +48,7 @@ asciidoctor {
'download-url' : "https://github.com/spring-projects/spring-session/archive/${ghTag}.zip",
'spring-session-version' : version,
'spring-version' : springVersion,
'hazelcast-version' : hazelcastVersion,
'docs-test-dir' : rootProject.projectDir.path + '/docs/src/test/java/',
'docs-test-resources-dir' : rootProject.projectDir.path + '/docs/src/test/resources/',
'samples-dir' : rootProject.projectDir.path + '/samples/',

View File

@@ -0,0 +1,191 @@
= Spring Session and Spring Security with Hazelcast
Tommy Ludwig; Rob Winch
:toc:
This guide describes how to use Spring Session along with Spring Security using Hazelcast as your data store.
It assumes you have already applied Spring Security to your application.
NOTE: The completed guide can be found in the <<hazelcast-spring-security-sample, Hazelcast Spring Security sample application>>.
== Updating Dependencies
Before you use Spring Session, you must ensure to update your dependencies.
If you are using Maven, ensure to add the following dependencies:
.pom.xml
[source,xml]
[subs="verbatim,attributes"]
----
<dependencies>
<!-- ... -->
<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast</artifactId>
<version>{hazelcast-version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<version>{spring-version}</version>
</dependency>
</dependencies>
----
ifeval::["{version-snapshot}" == "true"]
Since We are using a SNAPSHOT version, we need to ensure to add the Spring Snapshot Maven Repository.
Ensure you have the following in your pom.xml:
.pom.xml
[source,xml]
----
<repositories>
<!-- ... -->
<repository>
<id>spring-snapshot</id>
<url>https://repo.spring.io/libs-snapshot</url>
</repository>
</repositories>
----
endif::[]
ifeval::["{version-milestone}" == "true"]
Since We are using a Milestone version, we need to ensure to add the Spring Milestone Maven Repository.
Ensure you have the following in your pom.xml:
.pom.xml
[source,xml]
----
<repository>
<id>spring-milestone</id>
<url>https://repo.spring.io/libs-milestone</url>
</repository>
----
endif::[]
[[security-spring-configuration]]
== Spring Configuration
After adding the required dependencies, we can create our Spring configuration.
The Spring configuration is responsible for creating a Servlet Filter that replaces the `HttpSession` implementation with an implementation backed by Spring Session.
Add the following Spring Configuration:
[source,java]
----
include::{docs-test-dir}docs/http/HazelcastHttpSessionConfig.java[tags=config]
----
<1> The `@EnableHazelcastHttpSession` annotation creates a Spring Bean with the name of `springSessionRepositoryFilter` that implements Filter.
The filter is what is in charge of replacing the `HttpSession` implementation to be backed by Spring Session.
In this instance Spring Session is backed by Hazelcast.
<2> We create a `HazelcastInstance` that connects Spring Session to Hazelcast.
By default, an embedded instance of Hazelcast is started and connected to by the application.
For more information on configuring Hazelcast, refer to the http://docs.hazelcast.org/docs/latest/manual/html-single/hazelcast-documentation.html#hazelcast-configuration[reference documentation].
== Servlet Container Initialization
Our <<security-spring-configuration,Spring Configuration>> created a Spring Bean named `springSessionRepositoryFilter` that implements `Filter`.
The `springSessionRepositoryFilter` bean is responsible for replacing the `HttpSession` with a custom implementation that is backed by Spring Session.
In order for our `Filter` to do its magic, Spring needs to load our `Config` class.
Since our application is already loading Spring configuration using our `SecurityInitializer` class, we can simply add our Config class to it.
.src/main/java/sample/SecurityInitializer.java
[source,java]
----
include::{samples-dir}hazelcast-spring/src/main/java/sample/SecurityInitializer.java[tags=class]
----
Last we need to ensure that our Servlet Container (i.e. Tomcat) uses our `springSessionRepositoryFilter` for every request.
It is extremely important that Spring Session's `springSessionRepositoryFilter` is invoked before Spring Security's `springSecurityFilterChain`.
This ensures that the `HttpSession` that Spring Security uses is backed by Spring Session.
Fortunately, Spring Session provides a utility class named `AbstractHttpSessionApplicationInitializer` that makes this extremely easy.
You can find an example below:
.src/main/java/sample/Initializer.java
[source,java]
----
include::{samples-dir}hazelcast-spring/src/main/java/sample/Initializer.java[tags=class]
----
NOTE: The name of our class (Initializer) does not matter. What is important is that we extend `AbstractHttpSessionApplicationInitializer`.
By extending `AbstractHttpSessionApplicationInitializer` we ensure that the Spring Bean by the name `springSessionRepositoryFilter` is registered with our Servlet Container for every request before Spring Security's `springSecurityFilterChain`.
[[hazelcast-spring-security-sample]]
== Hazelcast Spring Security Sample Application
=== Running the Sample Application
You can run the sample by obtaining the {download-url}[source code] and invoking the following command:
[NOTE]
====
Hazelcast will run in embedded mode with your application by default, but if you want to connect
to a stand alone instance instead, you can configure it by following the instructions in the
http://docs.hazelcast.org/docs/latest/manual/html-single/hazelcast-documentation.html#hazelcast-configuration[reference documentation].
====
----
$ ./gradlew :samples:hazelcast-spring:tomcatRun
----
You should now be able to access the application at http://localhost:8080/
=== Exploring the security Sample Application
Try using the application. Enter the following to log in:
* **Username** _user_
* **Password** _password_
Now click the **Login** button.
You should now see a message indicating your are logged in with the user entered previously.
The user's information is stored in Hazelcast rather than Tomcat's `HttpSession` implementation.
=== How does it work?
Instead of using Tomcat's `HttpSession`, we are actually persisting the values in Hazelcast.
Spring Session replaces the `HttpSession` with an implementation that is backed by a `Map` in Hazelcast.
When Spring Security's `SecurityContextPersistenceFilter` saves the `SecurityContext` to the `HttpSession` it is then persisted into Hazelcast.
When a new `HttpSession` is created, Spring Session creates a cookie named SESSION in your browser that contains the id of your session.
Go ahead and view the cookies (click for help with https://developer.chrome.com/devtools/docs/resources#cookies[Chrome] or https://getfirebug.com/wiki/index.php/Cookies_Panel#Cookies_List[Firefox]).
=== Interact with the data store
If you like, you can remove the session using http://docs.hazelcast.org/docs/latest/manual/html-single/hazelcast-documentation.html#hazelcast-java-client[a Java client],
http://docs.hazelcast.org/docs/latest/manual/html-single/hazelcast-documentation.html#other-client-implementations[one of the other clients], or the
http://docs.hazelcast.org/docs/latest/manual/html-single/hazelcast-documentation.html#management-center[management center].
==== Using the console
For example, using the management center console after connecting to your Hazelcast node:
default> ns spring:session:sessions
spring:session:sessions> m.clear
TIP: The Hazelcast documentation has instructions for http://docs.hazelcast.org/docs/latest/manual/html-single/hazelcast-documentation.html#console[the console].
Alternatively, you can also delete the explicit key. Enter the following into the console ensuring to replace `7e8383a4-082c-4ffe-a4bc-c40fd3363c5e` with the value of your SESSION cookie:
spring:session:sessions> m.remove 7e8383a4-082c-4ffe-a4bc-c40fd3363c5e
Now visit the application at http://localhost:8080/ and observe that we are no longer authenticated.
==== Using the REST API
As described in the other clients section of the documentation, there is a
http://docs.hazelcast.org/docs/latest/manual/html-single/hazelcast-documentation.html#rest-client[REST API]
provided by the Hazelcast node(s).
For example, you could delete an individual key as follows (replacing `7e8383a4-082c-4ffe-a4bc-c40fd3363c5e` with the value of your SESSION cookie):
$ curl -v -X DELETE http://localhost:xxxxx/hazelcast/rest/maps/spring:session:sessions/7e8383a4-082c-4ffe-a4bc-c40fd3363c5e
TIP: The port number of the Hazelcast node will be printed to the console on startup. Replace `xxxxx` above with the port number.
Now observe that you are no longer authenticated with this session.

View File

@@ -71,7 +71,7 @@ If you are looking to get started with Spring Session, the best place to start i
[[samples-hazelcast-spring]]
| {gh-samples-url}hazelcast-spring[Hazelcast Spring]
| Demonstrates how to use Spring Session and Hazelcast with an existing Spring Security application.
| TBD
| link:guides/hazelcast-spring.html[Hazelcast Spring Guide]
|===
@@ -342,6 +342,43 @@ It is important to note that no infrastructure for session expirations is config
This is because things like session expiration are highly implementation dependent.
This means if you require cleaning up expired sessions, you are responsible for cleaning up the expired sessions.
[[api-enablehazelcasthttpsession]]
=== EnableHazelcastHttpSession
If you wish to use http://hazelcast.org/[Hazelcast] as your backing source for the `SessionRepository`, then the `@EnableHazelcastHttpSession` annotation
can be added to an `@Configuration` class. This extends the functionality provided by the `@EnableSpringHttpSession` annotation but makes the `SessionRepository` for you in Hazelcast.
You must provide a single `HazelcastInstance` bean for the configuration to work.
For example:
[source,java,indent=0]
----
include::{docs-test-dir}docs/http/HazelcastHttpSessionConfig.java[tags=config]
----
This will configure Hazelcast in embedded mode with default configuration.
See the http://docs.hazelcast.org/docs/latest/manual/html-single/hazelcast-documentation.html#hazelcast-configuration[Hazelcast documentation] for
detailed information on configuration options for Hazelcast.
[[api-enablehazelcasthttpsession-storage]]
==== Storage Details
Sessions will be stored in a distributed `Map` in Hazelcast using a <<api-mapsessionrepository,MapSessionRepository>>.
The `Map` interface methods will be used to `get()` and `put()` Sessions.
The expiration of a session in the `Map` is handled by Hazelcast's native support for configuring a map's `max-idle-seconds` setting.
Entries (sessions) that have been idle longer than the max idle setting will be automatically removed from the `Map`.
[[api-enablehazelcasthttpsession-customize]]
==== Basic Customization
You can use the following attributes on `@EnableHazelcastHttpSession` to customize the configuration:
* **maxInactiveIntervalInSeconds** - the amount of time before the session will expire in seconds. Default is 1800 seconds (30 minutes)
* **sessionMapName** - the name of the distributed `Map` that will be used in Hazelcast to store the session data.
[[api-enablehazelcasthttpsession-events]]
==== Session Events
Using a `MapListener` to respond to entries being added, evicted, and removed from the distributed `Map`, these events will trigger
publishing SessionCreatedEvent, SessionExpiredEvent, and SessionDeletedEvent events respectively using the `ApplicationEventPublisher`.
[[api-redisoperationssessionrepository]]
=== RedisOperationsSessionRepository

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 docs.http;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.data.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession;
import com.hazelcast.config.Config;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
//tag::config[]
@EnableHazelcastHttpSession // <1>
@Configuration
public class HazelcastHttpSessionConfig {
@Bean
public HazelcastInstance embeddedHazelcast() {
Config hazelcastConfig = new Config();
return Hazelcast.newHazelcastInstance(hazelcastConfig); // <2>
}
}
// end::config[]

View File

@@ -15,74 +15,34 @@
*/
package sample;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.MapSessionRepository;
import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
import org.springframework.session.ExpiringSession;
import org.springframework.session.data.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession;
import org.springframework.util.SocketUtils;
import com.hazelcast.config.MapConfig;
import com.hazelcast.config.NetworkConfig;
import com.hazelcast.config.SerializerConfig;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.core.IMap;
// tag::class[]
@EnableSpringHttpSession
@EnableHazelcastHttpSession(maxInactiveIntervalInSeconds = "300")
@Configuration
public class Config {
private String sessionMapName = "spring:session:sessions";
@Autowired
private ApplicationEventPublisher eventPublisher;
@Bean(destroyMethod = "shutdown")
public HazelcastInstance hazelcastInstance() {
com.hazelcast.config.Config cfg = new com.hazelcast.config.Config();
NetworkConfig netConfig = new NetworkConfig();
netConfig.setPort(SocketUtils.findAvailableTcpPort());
System.out.println("Hazelcast port #: " + netConfig.getPort());
cfg.setNetworkConfig(netConfig);
SerializerConfig serializer = new SerializerConfig().setTypeClass(
Object.class).setImplementation(new ObjectStreamSerializer());
cfg.getSerializationConfig().addSerializerConfig(serializer);
MapConfig mc = new MapConfig();
mc.setName(sessionMapName);
mc.setMaxIdleSeconds(60);
cfg.addMapConfig(mc);
return Hazelcast.newHazelcastInstance(cfg);
}
@Bean
public SessionRemovedListener removeListener() {
return new SessionRemovedListener(eventPublisher);
}
@Bean
public SessionEvictedListener evictListener() {
return new SessionEvictedListener(eventPublisher);
}
@Bean
public SessionCreatedListener addListener() {
return new SessionCreatedListener(eventPublisher);
}
@Bean
public MapSessionRepository sessionRepository(HazelcastInstance instance,
SessionRemovedListener removeListener, SessionEvictedListener evictListener,
SessionCreatedListener addListener) {
IMap<String, ExpiringSession> sessions = instance.getMap(sessionMapName);
sessions.addEntryListener(removeListener, true);
sessions.addEntryListener(evictListener, true);
sessions.addEntryListener(addListener, true);
return new MapSessionRepository(sessions);
}
}
// end::class[]

View File

@@ -1,43 +0,0 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 sample;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.session.ExpiringSession;
import org.springframework.session.events.SessionCreatedEvent;
import com.hazelcast.core.EntryEvent;
import com.hazelcast.map.listener.EntryAddedListener;
/**
*
* @author Mark Anderson
*
*/
public class SessionCreatedListener
implements EntryAddedListener<String, ExpiringSession> {
private ApplicationEventPublisher eventPublisher;
public SessionCreatedListener(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void entryAdded(EntryEvent<String, ExpiringSession> event) {
System.out.println("Session added: " + event);
eventPublisher.publishEvent(new SessionCreatedEvent(this, event.getValue()));
}
}

View File

@@ -1,44 +0,0 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 sample;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.session.ExpiringSession;
import org.springframework.session.events.SessionExpiredEvent;
import com.hazelcast.core.EntryEvent;
import com.hazelcast.map.listener.EntryEvictedListener;
/**
*
* @author Mark Anderson
*
*/
public class SessionEvictedListener
implements EntryEvictedListener<String, ExpiringSession> {
private ApplicationEventPublisher eventPublisher;
public SessionEvictedListener(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void entryEvicted(EntryEvent<String, ExpiringSession> event) {
System.out.println("Session removed: " + event);
eventPublisher.publishEvent(new SessionExpiredEvent(this, event.getOldValue()));
}
}

View File

@@ -1,44 +0,0 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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 sample;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.session.ExpiringSession;
import org.springframework.session.events.SessionDeletedEvent;
import com.hazelcast.core.EntryEvent;
import com.hazelcast.map.listener.EntryRemovedListener;
/**
*
* @author Mark Anderson
*
*/
public class SessionRemovedListener
implements EntryRemovedListener<String, ExpiringSession> {
private ApplicationEventPublisher eventPublisher;
public SessionRemovedListener(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void entryRemoved(EntryEvent<String, ExpiringSession> event) {
System.out.println("Session removed: " + event);
eventPublisher.publishEvent(new SessionDeletedEvent(this, event.getOldValue()));
}
}

View File

@@ -16,6 +16,7 @@ configurations {
dependencies {
optional "org.springframework.data:spring-data-redis:$springDataRedisVersion",
"com.hazelcast:hazelcast:$hazelcastVersion",
"org.springframework:spring-context:$springVersion",
"org.springframework:spring-web:$springVersion",
"org.springframework:spring-messaging:$springVersion",

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.springframework.session.data;
import org.springframework.context.ApplicationListener;
import org.springframework.session.events.AbstractSessionEvent;
public class SessionEventRegistry implements ApplicationListener<AbstractSessionEvent> {
private AbstractSessionEvent event;
private Object lock = new Object();
public void onApplicationEvent(AbstractSessionEvent event) {
this.event = event;
synchronized (lock) {
lock.notifyAll();
}
}
public void setLock(Object lock) {
this.lock = lock;
}
public void clear() {
this.event = null;
}
public boolean receivedEvent() throws InterruptedException {
return waitForEvent() != null;
}
@SuppressWarnings("unchecked")
public <E extends AbstractSessionEvent> E getEvent() throws InterruptedException {
return (E) waitForEvent();
}
@SuppressWarnings("unchecked")
private <E extends AbstractSessionEvent> E waitForEvent() throws InterruptedException {
synchronized(lock) {
if(event == null) {
lock.wait(3000);
}
}
return (E) event;
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.springframework.session.data.hazelcast;
import static org.fest.assertions.Assertions.assertThat;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.ExpiringSession;
import org.springframework.session.SessionRepository;
import org.springframework.session.data.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.util.SocketUtils;
import com.hazelcast.config.Config;
import com.hazelcast.config.NetworkConfig;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.core.IMap;
/**
* Integration tests that check the underlying data source - in this case
* Hazelcast.
*
* @author Tommy Ludwig
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@WebAppConfiguration
public class HazelcastRepositoryITests<S extends ExpiringSession> {
@Autowired
private HazelcastInstance hazelcast;
@Autowired
private SessionRepository<S> repository;
@Test
public void createAndDestorySession() {
S sessionToSave = repository.createSession();
String sessionId = sessionToSave.getId();
IMap<String, S> hazelcastMap = hazelcast.getMap("spring:session:sessions");
assertThat(hazelcastMap.size()).isEqualTo(0);
repository.save(sessionToSave);
assertThat(hazelcastMap.size()).isEqualTo(1);
assertThat(hazelcastMap.get(sessionId)).isEqualTo(sessionToSave);
repository.delete(sessionId);
assertThat(hazelcastMap.size()).isEqualTo(0);
}
@EnableHazelcastHttpSession
@Configuration
static class HazelcastSessionConfig {
@Bean
public HazelcastInstance embeddedHazelcast() {
Config hazelcastConfig = new Config();
NetworkConfig netConfig = new NetworkConfig();
netConfig.setPort(SocketUtils.findAvailableTcpPort());
hazelcastConfig.setNetworkConfig(netConfig);
return Hazelcast.newHazelcastInstance(hazelcastConfig);
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.springframework.session.data.hazelcast.config.annotation.web.http;
import static org.fest.assertions.Assertions.assertThat;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.session.ExpiringSession;
import org.springframework.session.Session;
import org.springframework.session.SessionRepository;
import org.springframework.session.data.SessionEventRegistry;
import org.springframework.session.events.SessionCreatedEvent;
import org.springframework.session.events.SessionDeletedEvent;
import org.springframework.session.events.SessionExpiredEvent;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.util.SocketUtils;
import com.hazelcast.config.Config;
import com.hazelcast.config.NetworkConfig;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
/**
* Ensure that the appropriate SessionEvents are fired at the expected times.
* Additionally ensure that the interactions with the {@link SessionRepository}
* abstraction behave as expected after each SessionEvent.
*
* @author Tommy Ludwig
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
@WebAppConfiguration
public class EnableHazelcastHttpSessionEventsTests<S extends ExpiringSession> {
private final static String MAX_INACTIVE_INTERVAL_IN_SECONDS_STR = "1";
private final static int MAX_INACTIVE_INTERVAL_IN_SECONDS = Integer.valueOf(MAX_INACTIVE_INTERVAL_IN_SECONDS_STR);
@Autowired
private SessionRepository<S> repository;
@Autowired
private SessionEventRegistry registry;
private final Object lock = new Object();
@Before
public void setup() {
registry.clear();
registry.setLock(lock);
}
@Test
public void saveSessionTest() throws InterruptedException {
String username = "saves-"+System.currentTimeMillis();
S sessionToSave = repository.createSession();
String expectedAttributeName = "a";
String expectedAttributeValue = "b";
sessionToSave.setAttribute(expectedAttributeName, expectedAttributeValue);
Authentication toSaveToken = new UsernamePasswordAuthenticationToken(username,"password", AuthorityUtils.createAuthorityList("ROLE_USER"));
SecurityContext toSaveContext = SecurityContextHolder.createEmptyContext();
toSaveContext.setAuthentication(toSaveToken);
sessionToSave.setAttribute("SPRING_SECURITY_CONTEXT", toSaveContext);
sessionToSave.setAttribute(Session.PRINCIPAL_NAME_ATTRIBUTE_NAME, username);
repository.save(sessionToSave);
assertThat(registry.receivedEvent()).isTrue();
assertThat(registry.getEvent()).isInstanceOf(SessionCreatedEvent.class);
Session session = repository.getSession(sessionToSave.getId());
assertThat(session.getId()).isEqualTo(sessionToSave.getId());
assertThat(session.getAttributeNames()).isEqualTo(sessionToSave.getAttributeNames());
assertThat(session.getAttribute(expectedAttributeName)).isEqualTo(sessionToSave.getAttribute(expectedAttributeName));
}
@Test
public void expiredSessionTest() throws InterruptedException {
S sessionToSave = repository.createSession();
repository.save(sessionToSave);
assertThat(registry.receivedEvent()).isTrue();
assertThat(registry.getEvent()).isInstanceOf(SessionCreatedEvent.class);
registry.clear();
assertThat(sessionToSave.getMaxInactiveIntervalInSeconds()).isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS);
synchronized (lock) {
lock.wait((sessionToSave.getMaxInactiveIntervalInSeconds() * 1000) + 1);
}
assertThat(registry.receivedEvent()).isTrue();
assertThat(registry.getEvent()).isInstanceOf(SessionExpiredEvent.class);
assertThat(repository.getSession(sessionToSave.getId())).isNull();
}
@Test
public void deletedSessionTest() throws InterruptedException {
S sessionToSave = repository.createSession();
repository.save(sessionToSave);
assertThat(registry.receivedEvent()).isTrue();
assertThat(registry.getEvent()).isInstanceOf(SessionCreatedEvent.class);
registry.clear();
repository.delete(sessionToSave.getId());
assertThat(registry.receivedEvent()).isTrue();
assertThat(registry.getEvent()).isInstanceOf(SessionDeletedEvent.class);
assertThat(repository.getSession(sessionToSave.getId())).isNull();
}
@Configuration
@EnableHazelcastHttpSession(maxInactiveIntervalInSeconds = MAX_INACTIVE_INTERVAL_IN_SECONDS_STR)
static class HazelcastSessionConfig {
@Bean
public HazelcastInstance embeddedHazelcast() {
Config hazelcastConfig = new Config();
NetworkConfig netConfig = new NetworkConfig();
netConfig.setPort(SocketUtils.findAvailableTcpPort());
hazelcastConfig.setNetworkConfig(netConfig);
return Hazelcast.newHazelcastInstance(hazelcastConfig);
}
@Bean
public SessionEventRegistry sessionEventRegistry() {
return new SessionEventRegistry();
}
}
}

View File

@@ -24,7 +24,6 @@ import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
@@ -35,9 +34,9 @@ import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.session.Session;
import org.springframework.session.data.SessionEventRegistry;
import org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.session.events.AbstractSessionEvent;
import org.springframework.session.events.SessionCreatedEvent;
import org.springframework.session.events.SessionDestroyedEvent;
import org.springframework.test.context.ContextConfiguration;
@@ -88,7 +87,7 @@ public class RedisOperationsSessionRepositoryITests {
Session session = repository.getSession(toSave.getId());
assertThat(session.getId()).isEqualTo(toSave.getId());
assertThat(session.getAttributeNames()).isEqualTo(session.getAttributeNames());
assertThat(session.getAttributeNames()).isEqualTo(toSave.getAttributeNames());
assertThat(session.getAttribute(expectedAttributeName)).isEqualTo(toSave.getAttribute(expectedAttributeName));
registry.clear();
@@ -146,41 +145,6 @@ public class RedisOperationsSessionRepositoryITests {
assertThat(findByPrincipalName.keySet()).excludes(toSave.getId());
}
static class SessionEventRegistry implements ApplicationListener<AbstractSessionEvent> {
private AbstractSessionEvent event;
private final Object lock = new Object();
public void onApplicationEvent(AbstractSessionEvent event) {
this.event = event;
synchronized (lock) {
lock.notifyAll();
}
}
public void clear() {
this.event = null;
}
public boolean receivedEvent() throws InterruptedException {
return waitForEvent() != null;
}
@SuppressWarnings("unchecked")
public <E extends AbstractSessionEvent> E getEvent() throws InterruptedException {
return (E) waitForEvent();
}
@SuppressWarnings("unchecked")
private <E extends AbstractSessionEvent> E waitForEvent() throws InterruptedException {
synchronized(lock) {
if(event == null) {
lock.wait(3000);
}
}
return (E) event;
}
}
@Configuration
@EnableRedisHttpSession(redisNamespace = "RedisOperationsSessionRepositoryITests")
static class Config {

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.springframework.session.data.hazelcast;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.session.ExpiringSession;
import org.springframework.session.events.SessionCreatedEvent;
import org.springframework.session.events.SessionDeletedEvent;
import org.springframework.session.events.SessionExpiredEvent;
import org.springframework.util.Assert;
import com.hazelcast.core.EntryEvent;
import com.hazelcast.map.listener.EntryAddedListener;
import com.hazelcast.map.listener.EntryEvictedListener;
import com.hazelcast.map.listener.EntryRemovedListener;
/**
* Listen for events on the Hazelcast-backed SessionRepository and
* translate those events into the corresponding Spring Session events.
* Publish the Spring Session events with the given {@link ApplicationEventPublisher}.
* <ul>
* <li>entryAdded --> {@link SessionCreatedEvent}</li>
* <li>entryEvicted --> {@link SessionExpiredEvent}</li>
* <li>entryRemoved --> {@link SessionDeletedEvent}</li>
* </ul>
*
* @author Tommy Ludwig
* @author Mark Anderson
* @since 1.1
*/
public class SessionEntryListener implements EntryAddedListener<String, ExpiringSession>,
EntryEvictedListener<String, ExpiringSession>, EntryRemovedListener<String, ExpiringSession> {
private static final Log logger = LogFactory.getLog(SessionEntryListener.class);
private ApplicationEventPublisher eventPublisher;
public SessionEntryListener(ApplicationEventPublisher eventPublisher) {
Assert.notNull(eventPublisher, "eventPublisher cannot be null");
this.eventPublisher = eventPublisher;
}
public void entryAdded(EntryEvent<String, ExpiringSession> event) {
logger.debug("Session created with id: " + event.getValue().getId());
this.eventPublisher.publishEvent(new SessionCreatedEvent(this, event.getValue()));
}
public void entryEvicted(EntryEvent<String, ExpiringSession> event) {
logger.debug("Session expired with id: " + event.getOldValue().getId());
this.eventPublisher.publishEvent(new SessionExpiredEvent(this, event.getOldValue()));
}
public void entryRemoved(EntryEvent<String, ExpiringSession> event) {
logger.debug("Session deleted with id: " + event.getOldValue().getId());
this.eventPublisher.publishEvent(new SessionDeletedEvent(this, event.getOldValue()));
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.springframework.session.data.hazelcast.config.annotation.web.http;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
/**
* Add this annotation to a {@code @Configuration} class to expose the
* SessionRepositoryFilter as a bean named "springSessionRepositoryFilter" and
* backed by Hazelcast. In order to leverage the annotation, a single {@link HazelcastInstance}
* must be provided. For example:
* <pre>
* <code>
* {@literal @Configuration}
* {@literal @EnableHazelcastHttpSession}
* public class HazelcastHttpSessionConfig {
*
* {@literal @Bean}
* public HazelcastInstance embeddedHazelcast() {
* Config hazelcastConfig = new Config();
* return Hazelcast.newHazelcastInstance(hazelcastConfig);
* }
*
* }
* </code>
* </pre>
*
* More advanced configurations can extend {@link HazelcastHttpSessionConfiguration} instead.
*
* @author Tommy Ludwig
* @since 1.1
* @see EnableSpringHttpSession
*/
@Retention(value=java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(value={java.lang.annotation.ElementType.TYPE})
@Documented
@Import(HazelcastHttpSessionConfiguration.class)
@Configuration
public @interface EnableHazelcastHttpSession {
/**
* This is the session timeout in seconds. By default, it is set to 1800 seconds (30 minutes).
* This should be a non-negative integer.
* <p>If you wish to use external configuration (outside of this annotation) to set this value, you can
* set this to "" (an empty String), which will prevent this configuration from overriding
* the external configuration for this value.</p>
*
* @return the seconds a session can be inactive before expiring
*/
String maxInactiveIntervalInSeconds() default "1800";
/**
* This is the name of the Map that will be used in Hazelcast to store the session data.
* Default is "spring:session:sessions".
* @return the name of the Map to store the sessions in Hazelcast
*/
String sessionMapName() default "spring:session:sessions";
}

View File

@@ -0,0 +1,156 @@
/*
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* 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.springframework.session.data.hazelcast.config.annotation.web.http;
import java.util.Map;
import javax.annotation.PreDestroy;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportAware;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.session.ExpiringSession;
import org.springframework.session.MapSessionRepository;
import org.springframework.session.SessionRepository;
import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration;
import org.springframework.session.data.hazelcast.SessionEntryListener;
import org.springframework.session.web.http.SessionRepositoryFilter;
import org.springframework.util.ClassUtils;
import com.hazelcast.config.MapConfig;
import com.hazelcast.core.HazelcastInstance;
import com.hazelcast.core.IMap;
/**
* Exposes the {@link SessionRepositoryFilter} as a bean named
* "springSessionRepositoryFilter". In order to use this a single
* {@link HazelcastInstance} must be exposed as a Bean.
*
* @author Tommy Ludwig
* @since 1.1
* @see EnableHazelcastHttpSession
*/
@Configuration
public class HazelcastHttpSessionConfiguration extends SpringHttpSessionConfiguration implements ImportAware, BeanClassLoaderAware {
/** This is the magic value to use if you do not want this configuration
* overriding the maxIdleSeconds value for the Map backing the session data. */
private static final String DO_NOT_CONFIGURE_INACTIVE_INTERVAL_STRING = "";
private ClassLoader beanClassLoader;
private Integer maxInactiveIntervalInSeconds = 1800;
private String sessionMapName = "spring:session:sessions";
private String sessionListenerUid;
private IMap<String, ExpiringSession> sessionsMap;
@Bean
public SessionRepository<ExpiringSession> sessionRepository(HazelcastInstance hazelcastInstance, SessionEntryListener sessionListener) {
configureSessionMap(hazelcastInstance);
this.sessionsMap = hazelcastInstance.getMap(sessionMapName);
this.sessionListenerUid = this.sessionsMap.addEntryListener(sessionListener, true);
MapSessionRepository sessionRepository = new MapSessionRepository(this.sessionsMap);
sessionRepository.setDefaultMaxInactiveInterval(maxInactiveIntervalInSeconds);
return sessionRepository;
}
@PreDestroy
private void removeSessionListener() {
this.sessionsMap.removeEntryListener(this.sessionListenerUid);
}
@Bean
public SessionEntryListener sessionListener(ApplicationEventPublisher eventPublisher) {
return new SessionEntryListener(eventPublisher);
}
/**
* Make a {@link MapConfig} for the given sessionMapName if one does not exist.
* Ensure that maxIdleSeconds is set to maxInactiveIntervalInSeconds for proper session expiration.
*
* @param hazelcastInstance the {@link HazelcastInstance} to configure
*/
private void configureSessionMap(HazelcastInstance hazelcastInstance) {
MapConfig sessionMapConfig = hazelcastInstance.getConfig().getMapConfig(sessionMapName);
if (this.maxInactiveIntervalInSeconds != null) {
sessionMapConfig.setMaxIdleSeconds(this.maxInactiveIntervalInSeconds);
}
}
public void setImportMetadata(AnnotationMetadata importMetadata) {
Map<String, Object> enableAttrMap = importMetadata.getAnnotationAttributes(EnableHazelcastHttpSession.class.getName());
AnnotationAttributes enableAttrs = AnnotationAttributes.fromMap(enableAttrMap);
if (enableAttrs == null) {
// search parent classes
Class<?> currentClass = ClassUtils.resolveClassName(importMetadata.getClassName(), beanClassLoader);
for (Class<?> classToInspect = currentClass; classToInspect != null; classToInspect = classToInspect.getSuperclass()) {
EnableHazelcastHttpSession enableHazelcastHttpSessionAnnotation = AnnotationUtils.findAnnotation(classToInspect, EnableHazelcastHttpSession.class);
if (enableHazelcastHttpSessionAnnotation == null) {
continue;
}
enableAttrMap = AnnotationUtils
.getAnnotationAttributes(enableHazelcastHttpSessionAnnotation);
enableAttrs = AnnotationAttributes.fromMap(enableAttrMap);
}
}
transferAnnotationAttributes(enableAttrs);
}
private void transferAnnotationAttributes(AnnotationAttributes enableAttrs) {
String maxInactiveIntervalString = enableAttrs.getString("maxInactiveIntervalInSeconds");
if (DO_NOT_CONFIGURE_INACTIVE_INTERVAL_STRING.equals(maxInactiveIntervalString)) {
this.maxInactiveIntervalInSeconds = null;
} else {
try {
this.maxInactiveIntervalInSeconds = Integer.parseInt(maxInactiveIntervalString);
} catch (NumberFormatException nfe) {
throw new IllegalArgumentException(
"@EnableHazelcastHttpSession's maxInactiveIntervalInSeconds expects an int format String but was '"
+ maxInactiveIntervalString + "' instead.", nfe);
}
}
this.sessionMapName = enableAttrs.getString("sessionMapName");
}
public void setMaxInactiveIntervalInSeconds(int maxInactiveIntervalInSeconds) {
this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds;
}
public String getSessionMapName() {
return this.sessionMapName;
}
public void setSessionMapName(String sessionMapName) {
this.sessionMapName = sessionMapName;
}
public void setBeanClassLoader(ClassLoader classLoader) {
this.beanClassLoader = classLoader;
}
}