From 21065b23c0309589c96c213ddb7fa3a94bfa7d6b Mon Sep 17 00:00:00 2001 From: Rob Winch Date: Wed, 12 Aug 2015 12:11:57 -0500 Subject: [PATCH] Add HttpSessionListener Support Fixes gh-4 --- docs/build.gradle | 1 + docs/src/docs/asciidoc/index.adoc | 25 ++++ .../AbstractHttpSessionListenerTests.java | 81 +++++++++++++ .../HttpSessionListenerJavaConfigTests.java | 44 +++++++ .../http/HttpSessionListenerXmlTests.java | 27 +++++ .../docs/http/RedisHttpSessionConfig.java | 35 ++++++ .../HttpSessionListenerXmlTests-context.xml | 22 ++++ .../http/RedisHttpSessionConfiguration.java | 18 ++- ...essionEventHttpSessionListenerAdapter.java | 82 ++++++++++++++ ...nEventHttpSessionListenerAdapterTests.java | 107 ++++++++++++++++++ 10 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 docs/src/test/java/docs/http/AbstractHttpSessionListenerTests.java create mode 100644 docs/src/test/java/docs/http/HttpSessionListenerJavaConfigTests.java create mode 100644 docs/src/test/java/docs/http/HttpSessionListenerXmlTests.java create mode 100644 docs/src/test/java/docs/http/RedisHttpSessionConfig.java create mode 100644 docs/src/test/resources/docs/http/HttpSessionListenerXmlTests-context.xml create mode 100644 spring-session/src/main/java/org/springframework/session/web/http/SessionEventHttpSessionListenerAdapter.java create mode 100644 spring-session/src/test/java/org/springframework/session/web/http/SessionEventHttpSessionListenerAdapterTests.java diff --git a/docs/build.gradle b/docs/build.gradle index ecb0a6a..7d597b3 100644 --- a/docs/build.gradle +++ b/docs/build.gradle @@ -12,6 +12,7 @@ dependencies { "org.springframework.data:spring-data-redis:$springDataRedisVersion", "org.springframework:spring-websocket:${springVersion}", "org.springframework:spring-messaging:${springVersion}", + "org.springframework.security:spring-security-web:${springSecurityVersion}", 'junit:junit:4.11', 'org.mockito:mockito-core:1.9.5', "org.springframework:spring-test:$springVersion", diff --git a/docs/src/docs/asciidoc/index.adoc b/docs/src/docs/asciidoc/index.adoc index a8ccb8f..0a24198 100644 --- a/docs/src/docs/asciidoc/index.adoc +++ b/docs/src/docs/asciidoc/index.adoc @@ -188,6 +188,31 @@ You can follow the basic steps for integration below, but you are encouraged to include::guides/rest.adoc[tags=config,leveloffset=+2] +[[httpsession-httpsessionlistener]] +=== HttpSessionListener + +Spring Session supports `HttpSessionListener` by translating `SessionDestroyedEvent` and `SessionCreatedEvent` into `HttpSessionEvent` by declaring `SessionEventHttpSessionListenerAdapter`. +To use this support, you need to: + +* Ensure your `SessionRepository` implementation supports and is configured to fire `SessionDestroyedEvent` and `SessionCreatedEvent`. +* Configure `SessionEventHttpSessionListenerAdapter` as a Spring bean. +* Inject every `HttpSessionListener` into the `SessionEventHttpSessionListenerAdapter` + +If you are using the configuration support documented in <>, then all you need to do is register every `HttpSessionListener` as a bean. +For example, assume you want to support Spring Security's concurrency control and need to use `HttpSessionEventPublisher` you can simply add `HttpSessionEventPublisher` as a bean. +In Java configuration, this might look like: + +[source,java,indent=0] +---- +include::{docs-test-dir}docs/http/RedisHttpSessionConfig.java[tags=config] +---- + +In XML configuration, this might look like: + +[source,xml,indent=0] +---- +include::{docs-test-resources-dir}docs/http/HttpSessionListenerXmlTests-context.xml[tags=config] +---- [[websocket]] == WebSocket Integration diff --git a/docs/src/test/java/docs/http/AbstractHttpSessionListenerTests.java b/docs/src/test/java/docs/http/AbstractHttpSessionListenerTests.java new file mode 100644 index 0000000..5578dc7 --- /dev/null +++ b/docs/src/test/java/docs/http/AbstractHttpSessionListenerTests.java @@ -0,0 +1,81 @@ +/* + * 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 static org.fest.assertions.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.security.core.session.SessionDestroyedEvent; +import org.springframework.session.MapSession; +import org.springframework.session.Session; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * @author Rob Winch + * @since 1.2 + */ +@RunWith(SpringJUnit4ClassRunner.class) +@WebAppConfiguration +public abstract class AbstractHttpSessionListenerTests { + @Autowired + ApplicationEventPublisher publisher; + + @Autowired + SecuritySessionDestroyedListener listener; + + @Test + public void springSessionDestroyedTranslatedToSpringSecurityDestroyed() { + Session session = new MapSession(); + + publisher.publishEvent(new org.springframework.session.events.SessionDestroyedEvent(this, session)); + + assertThat(listener.getEvent().getId()).isEqualTo(session.getId()); + } + + static RedisConnectionFactory createMockRedisConnection() { + RedisConnectionFactory factory = mock(RedisConnectionFactory.class); + RedisConnection connection = mock(RedisConnection.class); + + when(factory.getConnection()).thenReturn(connection); + return factory; + } + + static class SecuritySessionDestroyedListener implements ApplicationListener { + + private SessionDestroyedEvent event; + + /* (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + @Override + public void onApplicationEvent(SessionDestroyedEvent event) { + this.event = event; + } + + public SessionDestroyedEvent getEvent() { + return event; + } + } +} diff --git a/docs/src/test/java/docs/http/HttpSessionListenerJavaConfigTests.java b/docs/src/test/java/docs/http/HttpSessionListenerJavaConfigTests.java new file mode 100644 index 0000000..3aeecd8 --- /dev/null +++ b/docs/src/test/java/docs/http/HttpSessionListenerJavaConfigTests.java @@ -0,0 +1,44 @@ +/* + * 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.data.redis.connection.RedisConnectionFactory; +import org.springframework.test.context.ContextConfiguration; + +/** + * @author Rob Winch + * + */ +@ContextConfiguration(classes = { HttpSessionListenerJavaConfigTests.MockConfig.class, + RedisHttpSessionConfig.class }) +public class HttpSessionListenerJavaConfigTests extends AbstractHttpSessionListenerTests { + + @Configuration + static class MockConfig { + + @Bean + public static RedisConnectionFactory redisConnectionFactory() { + return AbstractHttpSessionListenerTests.createMockRedisConnection(); + } + + @Bean + public SecuritySessionDestroyedListener securitySessionDestroyedListener() { + return new SecuritySessionDestroyedListener(); + } + } +} diff --git a/docs/src/test/java/docs/http/HttpSessionListenerXmlTests.java b/docs/src/test/java/docs/http/HttpSessionListenerXmlTests.java new file mode 100644 index 0000000..43c900a --- /dev/null +++ b/docs/src/test/java/docs/http/HttpSessionListenerXmlTests.java @@ -0,0 +1,27 @@ +/* + * 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.test.context.ContextConfiguration; + +/** + * @author Rob Winch + * + */ +@ContextConfiguration +public class HttpSessionListenerXmlTests extends AbstractHttpSessionListenerTests { + +} diff --git a/docs/src/test/java/docs/http/RedisHttpSessionConfig.java b/docs/src/test/java/docs/http/RedisHttpSessionConfig.java new file mode 100644 index 0000000..13b9707 --- /dev/null +++ b/docs/src/test/java/docs/http/RedisHttpSessionConfig.java @@ -0,0 +1,35 @@ +/* + * 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.security.web.session.HttpSessionEventPublisher; +import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; + +// tag::config[] +@Configuration +@EnableRedisHttpSession +public class RedisHttpSessionConfig { + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + + // ... +} +// end::config[] \ No newline at end of file diff --git a/docs/src/test/resources/docs/http/HttpSessionListenerXmlTests-context.xml b/docs/src/test/resources/docs/http/HttpSessionListenerXmlTests-context.xml new file mode 100644 index 0000000..5c6a236 --- /dev/null +++ b/docs/src/test/resources/docs/http/HttpSessionListenerXmlTests-context.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java index 1a81a24..3045b4b 100644 --- a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java +++ b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java @@ -15,10 +15,13 @@ */ package org.springframework.session.data.redis.config.annotation.web.http; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Map; import javax.servlet.ServletContext; +import javax.servlet.http.HttpSessionListener; import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.beans.factory.InitializingBean; @@ -31,14 +34,12 @@ 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.data.redis.connection.MessageListener; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisOperations; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; -import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.session.ExpiringSession; @@ -47,6 +48,7 @@ import org.springframework.session.data.redis.RedisOperationsSessionRepository; import org.springframework.session.data.redis.config.ConfigureNotifyKeyspaceEventsAction; import org.springframework.session.data.redis.config.ConfigureRedisAction; import org.springframework.session.web.http.HttpSessionStrategy; +import org.springframework.session.web.http.SessionEventHttpSessionListenerAdapter; import org.springframework.session.web.http.SessionRepositoryFilter; import org.springframework.util.ClassUtils; @@ -72,6 +74,13 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad private ConfigureRedisAction configureRedisAction = new ConfigureNotifyKeyspaceEventsAction(); + private List httpSessionListeners = new ArrayList(); + + @Bean + public SessionEventHttpSessionListenerAdapter sessionEventHttpSessionListenerAdapter() { + return new SessionEventHttpSessionListenerAdapter(httpSessionListeners); + } + @Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, RedisOperationsSessionRepository messageListener) { @@ -140,6 +149,11 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad this.httpSessionStrategy = httpSessionStrategy; } + @Autowired(required = false) + public void setHttpSessionListeners(List listeners) { + this.httpSessionListeners = listeners; + } + @Bean public InitializingBean enableRedisKeyspaceNotificationsInitializer(RedisConnectionFactory connectionFactory) { return new EnableRedisKeyspaceNotificationsInitializer(connectionFactory, configureRedisAction); diff --git a/spring-session/src/main/java/org/springframework/session/web/http/SessionEventHttpSessionListenerAdapter.java b/spring-session/src/main/java/org/springframework/session/web/http/SessionEventHttpSessionListenerAdapter.java new file mode 100644 index 0000000..aa4566c --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/web/http/SessionEventHttpSessionListenerAdapter.java @@ -0,0 +1,82 @@ +/* + * 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.web.http; + +import java.util.List; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpSession; +import javax.servlet.http.HttpSessionEvent; +import javax.servlet.http.HttpSessionListener; + +import org.springframework.context.ApplicationListener; +import org.springframework.session.ExpiringSession; +import org.springframework.session.events.AbstractSessionEvent; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDestroyedEvent; +import org.springframework.web.context.ServletContextAware; + +/** + * Receives {@link SessionDestroyedEvent} and {@link SessionCreatedEvent} and + * translates them into {@link HttpSessionEvent} and submits the + * {@link HttpSessionEvent} to every registered {@link HttpSessionListener}. + * + * @author Rob Winch + * @since 1.1 + */ +public class SessionEventHttpSessionListenerAdapter implements ApplicationListener, ServletContextAware { + private final List listeners; + + private ServletContext context; + + public SessionEventHttpSessionListenerAdapter(List listeners) { + super(); + this.listeners = listeners; + } + + /* (non-Javadoc) + * @see org.springframework.context.ApplicationListener#onApplicationEvent(org.springframework.context.ApplicationEvent) + */ + public void onApplicationEvent(AbstractSessionEvent event) { + if(listeners.isEmpty()) { + return; + } + + HttpSessionEvent httpSessionEvent = createHttpSessionEvent(event); + + for(HttpSessionListener listener : listeners) { + if(event instanceof SessionDestroyedEvent) { + listener.sessionDestroyed(httpSessionEvent); + } else if(event instanceof SessionCreatedEvent) { + listener.sessionCreated(httpSessionEvent); + } + } + } + + private HttpSessionEvent createHttpSessionEvent(AbstractSessionEvent event) { + ExpiringSession session = event.getSession(); + HttpSession httpSession = new ExpiringSessionHttpSession(session, context); + HttpSessionEvent httpSessionEvent = new HttpSessionEvent(httpSession); + return httpSessionEvent; + } + + /* (non-Javadoc) + * @see org.springframework.web.context.ServletContextAware#setServletContext(javax.servlet.ServletContext) + */ + public void setServletContext(ServletContext servletContext) { + this.context = servletContext; + } +} diff --git a/spring-session/src/test/java/org/springframework/session/web/http/SessionEventHttpSessionListenerAdapterTests.java b/spring-session/src/test/java/org/springframework/session/web/http/SessionEventHttpSessionListenerAdapterTests.java new file mode 100644 index 0000000..d33af34 --- /dev/null +++ b/spring-session/src/test/java/org/springframework/session/web/http/SessionEventHttpSessionListenerAdapterTests.java @@ -0,0 +1,107 @@ +/* + * 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.web.http; + +import static org.fest.assertions.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.Collections; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpSessionEvent; +import javax.servlet.http.HttpSessionListener; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.session.MapSession; +import org.springframework.session.Session; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDestroyedEvent; + +/** + * @author Rob Winch + * @since 1.1 + */ +@RunWith(MockitoJUnitRunner.class) +public class SessionEventHttpSessionListenerAdapterTests { + @Mock + HttpSessionListener listener1; + @Mock + HttpSessionListener listener2; + @Mock + ServletContext servletContext; + @Captor + ArgumentCaptor sessionEvent; + + SessionDestroyedEvent destroyed; + SessionCreatedEvent created; + SessionEventHttpSessionListenerAdapter listener; + + @Before + public void setup() { + this.listener = new SessionEventHttpSessionListenerAdapter(Arrays.asList(listener1, listener2)); + + Session session = new MapSession(); + destroyed = new SessionDestroyedEvent(this, session); + created = new SessionCreatedEvent(this, session); + } + + // We want relaxed constructor that will allow for an empty listeners to + // make configuration easier (i.e. autowire all HttpSessionListeners and might get none) + @Test + public void constructorEmptyWorks() { + new SessionEventHttpSessionListenerAdapter(Collections.emptyList()); + } + + /** + * Make sure that we short circuit onApplicationEvent as early as possible if no listeners + */ + @Test + public void onApplicationEventEmptyListenersDoesNotUseEvent() { + listener = new SessionEventHttpSessionListenerAdapter(Collections.emptyList()); + destroyed = mock(SessionDestroyedEvent.class); + + listener.onApplicationEvent(destroyed); + + verifyZeroInteractions(destroyed, listener1, listener2); + } + + @Test + public void onApplicationEventDestroyed() { + listener.onApplicationEvent(destroyed); + + verify(listener1).sessionDestroyed(sessionEvent.capture()); + verify(listener2).sessionDestroyed(sessionEvent.capture()); + + assertThat(sessionEvent.getValue().getSession().getId()).isEqualTo(destroyed.getSessionId()); + } + + @Test + public void onApplicationEventCreated() { + listener.onApplicationEvent(created); + + verify(listener1).sessionCreated(sessionEvent.capture()); + verify(listener2).sessionCreated(sessionEvent.capture()); + + assertThat(sessionEvent.getValue().getSession().getId()).isEqualTo(created.getSessionId()); + } +}