diff --git a/gradle/dependency-management.gradle b/gradle/dependency-management.gradle index f5974a5e..2258d31c 100644 --- a/gradle/dependency-management.gradle +++ b/gradle/dependency-management.gradle @@ -1,6 +1,7 @@ dependencyManagement { imports { mavenBom 'io.projectreactor:reactor-bom:2020.0.12' + mavenBom 'com.fasterxml.jackson:jackson-bom:2.11.2' mavenBom 'org.junit:junit-bom:5.8.1' mavenBom 'org.springframework:spring-framework-bom:5.3.11' mavenBom 'org.springframework.data:spring-data-bom:2021.1.0-RC1' @@ -15,6 +16,8 @@ dependencyManagement { } dependency 'org.aspectj:aspectjweaver:1.9.7' + dependency 'ch.qos.logback:logback-core:1.2.3' + dependency 'com.google.code.findbugs:jsr305:3.0.2' dependency 'com.h2database:h2:1.4.200' dependency 'com.ibm.db2:jcc:11.5.6.0' dependency 'com.microsoft.sqlserver:mssql-jdbc:9.4.0.jre8' @@ -28,9 +31,20 @@ dependencyManagement { dependency 'mysql:mysql-connector-java:8.0.26' dependency 'org.apache.derby:derby:10.14.2.0' dependency 'org.assertj:assertj-core:3.21.0' + dependency 'org.hamcrest:hamcrest:2.1' dependency 'org.hsqldb:hsqldb:2.5.2' dependency 'org.mariadb.jdbc:mariadb-java-client:2.7.4' - dependency 'org.mockito:mockito-core:4.0.0' + dependencySet(group: 'org.mockito', version: '4.0.0') { + entry 'mockito-core' + entry 'mockito-junit-jupiter' + } + + dependencySet(group: 'org.mongodb', version: '4.2.3') { + entry 'mongodb-driver-core' + entry 'mongodb-driver-sync' + entry 'mongodb-driver-reactivestreams' + } dependency 'org.postgresql:postgresql:42.2.24' } } + diff --git a/settings.gradle b/settings.gradle index aced389d..67cdd447 100644 --- a/settings.gradle +++ b/settings.gradle @@ -13,6 +13,7 @@ plugins { rootProject.name = 'spring-session-build' include 'spring-session-core' +include 'spring-session-data-mongodb' include 'spring-session-data-redis' include 'spring-session-docs' include 'spring-session-hazelcast' diff --git a/spring-session-data-mongodb/spring-session-data-mongodb.gradle b/spring-session-data-mongodb/spring-session-data-mongodb.gradle new file mode 100644 index 00000000..a9c6f7b2 --- /dev/null +++ b/spring-session-data-mongodb/spring-session-data-mongodb.gradle @@ -0,0 +1,45 @@ +apply plugin: 'io.spring.convention.spring-module' + +description = "Spring Session and Spring MongoDB integration" + +dependencies { + + compile project(':spring-session-core') + + // Spring Data MongoDB + + compile("org.springframework.data:spring-data-mongodb") { + exclude group: "org.mongodb", module: "mongo-java-driver" + exclude group: "org.slf4j", module: "jcl-over-slf4j" + } + + // MongoDB dependencies + + optional "org.mongodb:mongodb-driver-core" + testCompile "org.mongodb:mongodb-driver-sync" + testCompile "org.mongodb:mongodb-driver-reactivestreams" + integrationTestCompile "org.testcontainers:mongodb" + + // Everything else + + compile "com.fasterxml.jackson.core:jackson-databind" + compile "org.springframework.security:spring-security-core" + compile "com.google.code.findbugs:jsr305" + + optional "io.projectreactor:reactor-core" + + testCompile "org.springframework:spring-web" + testCompile "org.springframework:spring-webflux" + testCompile "org.springframework.security:spring-security-config" + testCompile "org.springframework.security:spring-security-web" + testCompile "org.assertj:assertj-core" + testCompile "org.junit.jupiter:junit-jupiter-engine" + testCompile "org.junit.jupiter:junit-jupiter-params" + testCompile "org.springframework:spring-test" + testCompile "org.hamcrest:hamcrest" + testCompile "ch.qos.logback:logback-core" + testCompile "org.mockito:mockito-core" + testCompile "org.mockito:mockito-junit-jupiter" + testCompile "io.projectreactor:reactor-test" + testCompile "javax.servlet:javax.servlet-api" +} diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractClassLoaderTest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractClassLoaderTest.java new file mode 100644 index 00000000..2599f65e --- /dev/null +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractClassLoaderTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2018 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 + * + * https://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.mongo.integration; + +import java.lang.reflect.Field; + +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.core.serializer.DefaultDeserializer; +import org.springframework.core.serializer.support.DeserializingConverter; +import org.springframework.session.data.mongo.AbstractMongoSessionConverter; +import org.springframework.session.data.mongo.Assert; +import org.springframework.session.data.mongo.JdkMongoSessionConverter; +import org.springframework.util.ReflectionUtils; + +/** + * Verify container's {@link ClassLoader} is injected into session converter (reactive and + * traditional). + * + * @author Greg Turnquist + */ +public abstract class AbstractClassLoaderTest extends AbstractITest { + + @Autowired + T sessionRepository; + + @Autowired + ApplicationContext applicationContext; + + @Test + void verifyContainerClassLoaderLoadedIntoConverter() { + + Field mongoSessionConverterField = ReflectionUtils.findField(this.sessionRepository.getClass(), + "mongoSessionConverter"); + ReflectionUtils.makeAccessible( + Assert.requireNonNull(mongoSessionConverterField, "mongoSessionConverter must not be null!")); + AbstractMongoSessionConverter sessionConverter = (AbstractMongoSessionConverter) ReflectionUtils + .getField(mongoSessionConverterField, this.sessionRepository); + + AssertionsForClassTypes.assertThat(sessionConverter).isInstanceOf(JdkMongoSessionConverter.class); + + JdkMongoSessionConverter jdkMongoSessionConverter = (JdkMongoSessionConverter) sessionConverter; + + DeserializingConverter deserializingConverter = (DeserializingConverter) extractField( + JdkMongoSessionConverter.class, "deserializer", jdkMongoSessionConverter); + DefaultDeserializer deserializer = (DefaultDeserializer) extractField(DeserializingConverter.class, + "deserializer", deserializingConverter); + ClassLoader classLoader = (ClassLoader) extractField(DefaultDeserializer.class, "classLoader", deserializer); + + AssertionsForClassTypes.assertThat(classLoader).isEqualTo(this.applicationContext.getClassLoader()); + } + + private static Object extractField(Class clazz, String fieldName, Object obj) { + + Field field = ReflectionUtils.findField(clazz, fieldName); + ReflectionUtils.makeAccessible(Assert.requireNonNull(field, fieldName + " must not be null!")); + return ReflectionUtils.getField(field, obj); + } + +} diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractITest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractITest.java new file mode 100644 index 00000000..5616ba38 --- /dev/null +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractITest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo.integration; + +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * Base class for repositories integration tests + * + * @author Jakub Kubrynski + */ +@ExtendWith(SpringExtension.class) +@WebAppConfiguration +public abstract class AbstractITest { + + protected SecurityContext context; + + protected SecurityContext changedContext; + + // @Autowired(required = false) + // protected SessionEventRegistry registry; + + @BeforeEach + void setup() { + + // if (this.registry != null) { + // this.registry.clear(); + // } + + this.context = SecurityContextHolder.createEmptyContext(); + this.context.setAuthentication(new UsernamePasswordAuthenticationToken("username-" + UUID.randomUUID(), "na", + AuthorityUtils.createAuthorityList("ROLE_USER"))); + + this.changedContext = SecurityContextHolder.createEmptyContext(); + this.changedContext.setAuthentication(new UsernamePasswordAuthenticationToken( + "changedContext-" + UUID.randomUUID(), "na", AuthorityUtils.createAuthorityList("ROLE_USER"))); + } + +} diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractMongoRepositoryITest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractMongoRepositoryITest.java new file mode 100644 index 00000000..58c5445b --- /dev/null +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/AbstractMongoRepositoryITest.java @@ -0,0 +1,408 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo.integration; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.MongoTemplate; +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.FindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.session.data.mongo.MongoIndexedSessionRepository; +import org.springframework.session.data.mongo.MongoSession; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Abstract base class for {@link MongoIndexedSessionRepository} tests. + * + * @author Jakub Kubrynski + * @author Vedran Pavic + * @author Greg Turnquist + */ +public abstract class AbstractMongoRepositoryITest extends AbstractITest { + + protected static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + protected static final String INDEX_NAME = FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME; + + @Autowired + protected MongoIndexedSessionRepository repository; + + @Test + void saves() { + + String username = "saves-" + System.currentTimeMillis(); + + MongoSession toSave = this.repository.createSession(); + String expectedAttributeName = "a"; + String expectedAttributeValue = "b"; + toSave.setAttribute(expectedAttributeName, expectedAttributeValue); + Authentication toSaveToken = new UsernamePasswordAuthenticationToken(username, "password", + AuthorityUtils.createAuthorityList("ROLE_USER")); + SecurityContext toSaveContext = SecurityContextHolder.createEmptyContext(); + toSaveContext.setAuthentication(toSaveToken); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, toSaveContext); + toSave.setAttribute(INDEX_NAME, username); + + this.repository.save(toSave); + + Session session = this.repository.findById(toSave.getId()); + + assertThat(session.getId()).isEqualTo(toSave.getId()); + assertThat(session.getAttributeNames()).isEqualTo(toSave.getAttributeNames()); + assertThat(session.getAttribute(expectedAttributeName)) + .isEqualTo(toSave.getAttribute(expectedAttributeName)); + + this.repository.deleteById(toSave.getId()); + + String id = toSave.getId(); + assertThat(this.repository.findById(id)).isNull(); + } + + @Test + void putAllOnSingleAttrDoesNotRemoveOld() { + + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute("a", "b"); + + this.repository.save(toSave); + toSave = this.repository.findById(toSave.getId()); + + toSave.setAttribute("1", "2"); + + this.repository.save(toSave); + toSave = this.repository.findById(toSave.getId()); + + Session session = this.repository.findById(toSave.getId()); + assertThat(session.getAttributeNames().size()).isEqualTo(2); + assertThat(session.getAttribute("a")).isEqualTo("b"); + assertThat(session.getAttribute("1")).isEqualTo("2"); + + this.repository.deleteById(toSave.getId()); + } + + @Test + void findByPrincipalName() throws Exception { + + String principalName = "findByPrincipalName" + UUID.randomUUID(); + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + principalName); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + + this.repository.deleteById(toSave.getId()); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalName); + + assertThat(findByPrincipalName).hasSize(0); + assertThat(findByPrincipalName.keySet()).doesNotContain(toSave.getId()); + } + + @Test + void nonExistentSessionShouldNotBreakMongo() { + this.repository.deleteById("doesn't exist"); + } + + @Test + void findByPrincipalNameNoPrincipalNameChange() throws Exception { + + String principalName = "findByPrincipalNameNoPrincipalNameChange" + UUID.randomUUID(); + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + principalName); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByPrincipalNameNoPrincipalNameChangeReload() throws Exception { + + String principalName = "findByPrincipalNameNoPrincipalNameChangeReload" + UUID.randomUUID(); + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave); + + toSave = this.repository.findById(toSave.getId()); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + principalName); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByDeletedPrincipalName() throws Exception { + + String principalName = "findByDeletedPrincipalName" + UUID.randomUUID(); + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave); + + toSave.setAttribute(INDEX_NAME, null); + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + principalName); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByChangedPrincipalName() throws Exception { + + String principalName = "findByChangedPrincipalName" + UUID.randomUUID(); + String principalNameChanged = "findByChangedPrincipalName" + UUID.randomUUID(); + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave); + + toSave.setAttribute(INDEX_NAME, principalNameChanged); + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + principalName); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByDeletedPrincipalNameReload() throws Exception { + + String principalName = "findByDeletedPrincipalName" + UUID.randomUUID(); + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave); + + MongoSession getSession = this.repository.findById(toSave.getId()); + getSession.setAttribute(INDEX_NAME, null); + this.repository.save(getSession); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + principalName); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByChangedPrincipalNameReload() throws Exception { + + String principalName = "findByChangedPrincipalName" + UUID.randomUUID(); + String principalNameChanged = "findByChangedPrincipalName" + UUID.randomUUID(); + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(INDEX_NAME, principalName); + + this.repository.save(toSave); + + MongoSession getSession = this.repository.findById(toSave.getId()); + + getSession.setAttribute(INDEX_NAME, principalNameChanged); + this.repository.save(getSession); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + principalName); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, principalNameChanged); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findBySecurityPrincipalName() throws Exception { + + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + getSecurityName()); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + + this.repository.deleteById(toSave.getId()); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, getSecurityName()); + + assertThat(findByPrincipalName).hasSize(0); + assertThat(findByPrincipalName.keySet()).doesNotContain(toSave.getId()); + } + + @Test + void findByPrincipalNameNoSecurityPrincipalNameChange() throws Exception { + + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + getSecurityName()); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByDeletedSecurityPrincipalName() throws Exception { + + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave); + + toSave.setAttribute(SPRING_SECURITY_CONTEXT, null); + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + getSecurityName()); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByChangedSecurityPrincipalName() throws Exception { + + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave); + + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + getSecurityName()); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, getChangedSecurityName()); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void findByChangedSecurityPrincipalNameReload() throws Exception { + + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave); + + MongoSession getSession = this.repository.findById(toSave.getId()); + + getSession.setAttribute(SPRING_SECURITY_CONTEXT, this.changedContext); + this.repository.save(getSession); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + getSecurityName()); + assertThat(findByPrincipalName).isEmpty(); + + findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, getChangedSecurityName()); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + @Test + void loadExpiredSession() throws Exception { + + // given + MongoSession expiredSession = this.repository.createSession(); + Instant thirtyOneMinutesAgo = Instant.ofEpochMilli(System.currentTimeMillis()).minus(Duration.ofMinutes(31)); + expiredSession.setLastAccessedTime(thirtyOneMinutesAgo); + this.repository.save(expiredSession); + + // then + MongoSession expiredSessionFromDb = this.repository.findById(expiredSession.getId()); + assertThat(expiredSessionFromDb).isNull(); + } + + protected String getSecurityName() { + return this.context.getAuthentication().getName(); + } + + protected String getChangedSecurityName() { + return this.changedContext.getAuthentication().getName(); + } + + protected static class BaseConfig { + + private static final String DOCKER_IMAGE = "mongo:4.0.10"; + + @Bean(initMethod = "start", destroyMethod = "stop") + public MongoDBContainer mongoContainer() { + return new MongoDBContainer(DOCKER_IMAGE).withExposedPorts(27017); + } + + @Bean + public MongoOperations mongoOperations(MongoDBContainer mongoContainer) { + + MongoClient mongo = MongoClients.create( + "mongodb://" + mongoContainer.getContainerIpAddress() + ":" + mongoContainer.getFirstMappedPort()); + return new MongoTemplate(mongo, "test"); + } + + } + +} diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoDbDeleteJacksonSessionVerificationTest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoDbDeleteJacksonSessionVerificationTest.java new file mode 100644 index 00000000..9fa5b161 --- /dev/null +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoDbDeleteJacksonSessionVerificationTest.java @@ -0,0 +1,199 @@ +/* + * Copyright 2019 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 + * + * https://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.mongo.integration; + +import java.net.URI; + +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoClients; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.MongoDBContainer; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.session.data.mongo.AbstractMongoSessionConverter; +import org.springframework.session.data.mongo.JacksonMongoSessionConverter; +import org.springframework.session.data.mongo.config.annotation.web.reactive.EnableMongoWebSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.reactive.server.FluxExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.function.BodyInserters; + +/** + * @author Boris Finkelshteyn + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +public class MongoDbDeleteJacksonSessionVerificationTest { + + @Autowired + ApplicationContext ctx; + + WebTestClient client; + + @BeforeEach + void setUp() { + this.client = WebTestClient.bindToApplicationContext(this.ctx).build(); + } + + @Test + void logoutShouldDeleteOldSessionFromMongoDB() { + + // 1. Login and capture the SESSION cookie value. + + FluxExchangeResult loginResult = this.client.post().uri("/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) // + .body(BodyInserters // + .fromFormData("username", "admin") // + .with("password", "password")) // + .exchange() // + .returnResult(String.class); + + AssertionsForClassTypes.assertThat(loginResult.getResponseHeaders().getLocation()).isEqualTo(URI.create("/")); + + String originalSessionId = loginResult.getResponseCookies().getFirst("SESSION").getValue(); + + // 2. Fetch a protected resource using the SESSION cookie. + + this.client.get().uri("/hello") // + .cookie("SESSION", originalSessionId) // + .exchange() // + .expectStatus().isOk() // + .returnResult(String.class).getResponseBody() // + .as(StepVerifier::create) // + .expectNext("HelloWorld") // + .verifyComplete(); + + // 3. Logout using the SESSION cookie, and capture the new SESSION cookie. + + String newSessionId = this.client.post().uri("/logout") // + .cookie("SESSION", originalSessionId) // + .exchange() // + .expectStatus().isFound() // + .returnResult(String.class).getResponseCookies().getFirst("SESSION").getValue(); + + AssertionsForClassTypes.assertThat(newSessionId).isNotEqualTo(originalSessionId); + + // 4. Verify the new SESSION cookie is not yet authorized. + + this.client.get().uri("/hello") // + .cookie("SESSION", newSessionId) // + .exchange() // + .expectStatus().isFound() // + .expectHeader() + .value(HttpHeaders.LOCATION, (value) -> AssertionsForClassTypes.assertThat(value).isEqualTo("/login")); + + // 5. Verify the original SESSION cookie no longer works. + + this.client.get().uri("/hello") // + .cookie("SESSION", originalSessionId) // + .exchange() // + .expectStatus().isFound() // + .expectHeader() + .value(HttpHeaders.LOCATION, (value) -> AssertionsForClassTypes.assertThat(value).isEqualTo("/login")); + } + + @RestController + static class TestController { + + @GetMapping("/hello") + ResponseEntity hello() { + return ResponseEntity.ok("HelloWorld"); + } + + } + + @EnableWebFluxSecurity + static class SecurityConfig { + + @Bean + SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + return http // + .logout()// + /**/.and() // + .formLogin() // + /**/.and() // + .csrf().disable() // + .authorizeExchange() // + .anyExchange().authenticated() // + /**/.and() // + .build(); + } + + @Bean + MapReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsService(User.withDefaultPasswordEncoder() // + .username("admin") // + .password("password") // + .roles("USER,ADMIN") // + .build()); + } + + @Bean + AbstractMongoSessionConverter mongoSessionConverter() { + return new JacksonMongoSessionConverter(); + } + + } + + @Configuration + @EnableWebFlux + @EnableMongoWebSession + static class Config { + + private static final String DOCKER_IMAGE = "mongo:4.0.10"; + + @Bean(initMethod = "start", destroyMethod = "stop") + MongoDBContainer mongoContainer() { + return new MongoDBContainer(DOCKER_IMAGE).withExposedPorts(27017); + } + + @Bean + ReactiveMongoOperations mongoOperations(MongoDBContainer mongoContainer) { + + MongoClient mongo = MongoClients.create( + "mongodb://" + mongoContainer.getContainerIpAddress() + ":" + mongoContainer.getFirstMappedPort()); + return new ReactiveMongoTemplate(mongo, "DB_Name_DeleteJacksonSessionVerificationTest"); + } + + @Bean + TestController controller() { + return new TestController(); + } + + } + +} diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoDbLogoutVerificationTest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoDbLogoutVerificationTest.java new file mode 100644 index 00000000..c0675211 --- /dev/null +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoDbLogoutVerificationTest.java @@ -0,0 +1,194 @@ +/* + * Copyright 2019 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 + * + * https://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.mongo.integration; + +import java.net.URI; + +import com.mongodb.reactivestreams.client.MongoClient; +import com.mongodb.reactivestreams.client.MongoClients; +import org.assertj.core.api.AssertionsForClassTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.testcontainers.containers.MongoDBContainer; +import reactor.test.StepVerifier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.session.data.mongo.config.annotation.web.reactive.EnableMongoWebSession; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.reactive.server.FluxExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.function.BodyInserters; + +/** + * @author Greg Turnquist + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +public class MongoDbLogoutVerificationTest { + + @Autowired + ApplicationContext ctx; + + WebTestClient client; + + @BeforeEach + void setUp() { + this.client = WebTestClient.bindToApplicationContext(this.ctx).build(); + } + + @Test + void logoutShouldDeleteOldSessionFromMongoDB() { + + // 1. Login and capture the SESSION cookie value. + + FluxExchangeResult loginResult = this.client.post().uri("/login") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) // + .body(BodyInserters // + .fromFormData("username", "admin") // + .with("password", "password")) // + .exchange() // + .returnResult(String.class); + + AssertionsForClassTypes.assertThat(loginResult.getResponseHeaders().getLocation()).isEqualTo(URI.create("/")); + + String originalSessionId = loginResult.getResponseCookies().getFirst("SESSION").getValue(); + + // 2. Fetch a protected resource using the SESSION cookie. + + this.client.get().uri("/hello") // + .cookie("SESSION", originalSessionId) // + .exchange() // + .expectStatus().isOk() // + .returnResult(String.class).getResponseBody() // + .as(StepVerifier::create) // + .expectNext("HelloWorld") // + .verifyComplete(); + + // 3. Logout using the SESSION cookie, and capture the new SESSION cookie. + + String newSessionId = this.client.post().uri("/logout") // + .cookie("SESSION", originalSessionId) // + .exchange() // + .expectStatus().isFound() // + .returnResult(String.class).getResponseCookies().getFirst("SESSION").getValue(); + + AssertionsForClassTypes.assertThat(newSessionId).isNotEqualTo(originalSessionId); + + // 4. Verify the new SESSION cookie is not yet authorized. + + this.client.get().uri("/hello") // + .cookie("SESSION", newSessionId) // + .exchange() // + .expectStatus().isFound() // + .expectHeader() + .value(HttpHeaders.LOCATION, (value) -> AssertionsForClassTypes.assertThat(value).isEqualTo("/login")); + + // 5. Verify the original SESSION cookie no longer works. + + this.client.get().uri("/hello") // + .cookie("SESSION", originalSessionId) // + .exchange() // + .expectStatus().isFound() // + .expectHeader() + .value(HttpHeaders.LOCATION, (value) -> AssertionsForClassTypes.assertThat(value).isEqualTo("/login")); + } + + @RestController + static class TestController { + + @GetMapping("/hello") + ResponseEntity hello() { + return ResponseEntity.ok("HelloWorld"); + } + + } + + @EnableWebFluxSecurity + static class SecurityConfig { + + @Bean + SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + + return http // + .logout()// + /**/.and() // + .formLogin() // + /**/.and() // + .csrf().disable() // + .authorizeExchange() // + .anyExchange().authenticated() // + /**/.and() // + .build(); + } + + @Bean + MapReactiveUserDetailsService userDetailsService() { + + return new MapReactiveUserDetailsService(User.withDefaultPasswordEncoder() // + .username("admin") // + .password("password") // + .roles("USER,ADMIN") // + .build()); + } + + } + + @Configuration + @EnableWebFlux + @EnableMongoWebSession + static class Config { + + private static final String DOCKER_IMAGE = "mongo:4.0.10"; + + @Bean(initMethod = "start", destroyMethod = "stop") + MongoDBContainer mongoContainer() { + return new MongoDBContainer(DOCKER_IMAGE).withExposedPorts(27017); + } + + @Bean + ReactiveMongoOperations mongoOperations(MongoDBContainer mongoContainer) { + + MongoClient mongo = MongoClients.create( + "mongodb://" + mongoContainer.getContainerIpAddress() + ":" + mongoContainer.getFirstMappedPort()); + return new ReactiveMongoTemplate(mongo, "test"); + } + + @Bean + TestController controller() { + return new TestController(); + } + + } + +} diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoRepositoryJacksonITest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoRepositoryJacksonITest.java new file mode 100644 index 00000000..babe5815 --- /dev/null +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoRepositoryJacksonITest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo.integration; + +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.geo.GeoModule; +import org.springframework.session.data.mongo.AbstractMongoSessionConverter; +import org.springframework.session.data.mongo.JacksonMongoSessionConverter; +import org.springframework.session.data.mongo.MongoSession; +import org.springframework.session.data.mongo.config.annotation.web.http.EnableMongoHttpSession; +import org.springframework.test.context.ContextConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for + * {@link org.springframework.session.data.mongo.MongoIndexedSessionRepository} that use + * {@link JacksonMongoSessionConverter} based session serialization. + * + * @author Jakub Kubrynski + * @author Vedran Pavic + * @author Greg Turnquist + */ +@ContextConfiguration +public class MongoRepositoryJacksonITest extends AbstractMongoRepositoryITest { + + @Test + void findByCustomIndex() throws Exception { + + MongoSession toSave = this.repository.createSession(); + String cartId = "cart-" + UUID.randomUUID(); + toSave.setAttribute("cartId", cartId); + + this.repository.save(toSave); + + Map findByCartId = this.repository.findByIndexNameAndIndexValue("cartId", cartId); + + assertThat(findByCartId).hasSize(1); + assertThat(findByCartId.keySet()).containsOnly(toSave.getId()); + } + + // tag::sample[] + @Configuration + @EnableMongoHttpSession + static class Config extends BaseConfig { + + @Bean + AbstractMongoSessionConverter mongoSessionConverter() { + return new JacksonMongoSessionConverter(Collections.singletonList(new GeoModule())); + } + + } + // end::sample[] + +} diff --git a/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoRepositoryJdkSerializationITest.java b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoRepositoryJdkSerializationITest.java new file mode 100644 index 00000000..228e6624 --- /dev/null +++ b/spring-session-data-mongodb/src/integration-test/java/org/springframework/session/data/mongo/integration/MongoRepositoryJdkSerializationITest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo.integration; + +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.data.mongo.AbstractMongoSessionConverter; +import org.springframework.session.data.mongo.JdkMongoSessionConverter; +import org.springframework.session.data.mongo.MongoSession; +import org.springframework.session.data.mongo.config.annotation.web.http.EnableMongoHttpSession; +import org.springframework.test.context.ContextConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for + * {@link org.springframework.session.data.mongo.MongoIndexedSessionRepository} that use + * {@link JdkMongoSessionConverter} based session serialization. + * + * @author Jakub Kubrynski + * @author Vedran Pavic + * @author Greg Turnquist + */ +@ContextConfiguration +public class MongoRepositoryJdkSerializationITest extends AbstractMongoRepositoryITest { + + @Test + void findByDeletedSecurityPrincipalNameReload() throws Exception { + + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave); + + MongoSession getSession = this.repository.findById(toSave.getId()); + getSession.setAttribute(INDEX_NAME, null); + this.repository.save(getSession); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + getChangedSecurityName()); + + assertThat(findByPrincipalName).isEmpty(); + } + + @Test + void findByPrincipalNameNoSecurityPrincipalNameChangeReload() throws Exception { + + MongoSession toSave = this.repository.createSession(); + toSave.setAttribute(SPRING_SECURITY_CONTEXT, this.context); + + this.repository.save(toSave); + + toSave = this.repository.findById(toSave.getId()); + + toSave.setAttribute("other", "value"); + this.repository.save(toSave); + + Map findByPrincipalName = this.repository.findByIndexNameAndIndexValue(INDEX_NAME, + getSecurityName()); + + assertThat(findByPrincipalName).hasSize(1); + assertThat(findByPrincipalName.keySet()).containsOnly(toSave.getId()); + } + + // tag::sample[] + @Configuration + @EnableMongoHttpSession + static class Config extends BaseConfig { + + @Bean + AbstractMongoSessionConverter mongoSessionConverter() { + return new JdkMongoSessionConverter(Duration.ofMinutes(30)); + } + + } + // end::sample[] + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/AbstractMongoSessionConverter.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/AbstractMongoSessionConverter.java new file mode 100644 index 00000000..2245b7a5 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/AbstractMongoSessionConverter.java @@ -0,0 +1,129 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo; + +import java.util.Collections; +import java.util.Set; + +import com.mongodb.DBObject; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.bson.Document; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexInfo; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.lang.Nullable; +import org.springframework.session.DelegatingIndexResolver; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.IndexResolver; +import org.springframework.session.PrincipalNameIndexResolver; + +/** + * Base class for serializing and deserializing session objects. To create custom + * serializer you have to implement this interface and simply register your class as a + * bean. + * + * @author Jakub Kubrynski + * @author Greg Turnquist + * @since 1.2 + */ +public abstract class AbstractMongoSessionConverter implements GenericConverter { + + static final String EXPIRE_AT_FIELD_NAME = "expireAt"; + + private static final Log LOG = LogFactory.getLog(AbstractMongoSessionConverter.class); + + private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT"; + + private IndexResolver indexResolver = new DelegatingIndexResolver<>( + new PrincipalNameIndexResolver<>()); + + /** + * Returns query to be executed to return sessions based on a particular index. + * @param indexName name of the index + * @param indexValue value to query against + * @return built query or null if indexName is not supported + */ + @Nullable + protected abstract Query getQueryForIndex(String indexName, Object indexValue); + + /** + * Method ensures that there is a TTL index on {@literal expireAt} field. It's has + * {@literal expireAfterSeconds} set to zero seconds, so the expiration time is + * controlled by the application. It can be extended in custom converters when there + * is a need for creating additional custom indexes. + * @param sessionCollectionIndexes {@link IndexOperations} to use + */ + protected void ensureIndexes(IndexOperations sessionCollectionIndexes) { + + for (IndexInfo info : sessionCollectionIndexes.getIndexInfo()) { + if (EXPIRE_AT_FIELD_NAME.equals(info.getName())) { + LOG.debug("TTL index on field " + EXPIRE_AT_FIELD_NAME + " already exists"); + return; + } + } + + LOG.info("Creating TTL index on field " + EXPIRE_AT_FIELD_NAME); + + sessionCollectionIndexes + .ensureIndex(new Index(EXPIRE_AT_FIELD_NAME, Sort.Direction.ASC).named(EXPIRE_AT_FIELD_NAME).expire(0)); + } + + protected String extractPrincipal(MongoSession expiringSession) { + + return this.indexResolver.resolveIndexesFor(expiringSession) + .get(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); + } + + public Set getConvertibleTypes() { + + return Collections.singleton(new ConvertiblePair(DBObject.class, MongoSession.class)); + } + + @SuppressWarnings("unchecked") + @Nullable + public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + + if (source == null) { + return null; + } + + if (DBObject.class.isAssignableFrom(sourceType.getType())) { + return convert(new Document(((DBObject) source).toMap())); + } + else if (Document.class.isAssignableFrom(sourceType.getType())) { + return convert((Document) source); + } + else { + return convert((MongoSession) source); + } + } + + protected abstract DBObject convert(MongoSession session); + + protected abstract MongoSession convert(Document sessionWrapper); + + public void setIndexResolver(IndexResolver indexResolver) { + this.indexResolver = Assert.requireNonNull(indexResolver, "indexResolver must not be null!"); + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/Assert.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/Assert.java new file mode 100644 index 00000000..5d9f7bf6 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/Assert.java @@ -0,0 +1,39 @@ +/* + * Copyright 2019 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 + * + * https://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.mongo; + +import org.springframework.lang.Nullable; + +/** + * Utility to verify non null fields. + * + * @author Greg Turnquist + */ +public final class Assert { + + private Assert() { + } + + public static T requireNonNull(@Nullable T item, String message) { + + if (item == null) { + throw new IllegalArgumentException(message); + } + + return item; + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JacksonMongoSessionConverter.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JacksonMongoSessionConverter.java new file mode 100644 index 00000000..7b5bdf76 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JacksonMongoSessionConverter.java @@ -0,0 +1,187 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo; + +import java.io.IOException; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.Module; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.bson.Document; +import org.bson.json.JsonMode; +import org.bson.json.JsonWriterSettings; + +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.lang.Nullable; +import org.springframework.security.jackson2.SecurityJackson2Modules; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.util.Assert; + +/** + * {@code AbstractMongoSessionConverter} implementation using Jackson. + * + * @author Jakub Kubrynski + * @author Greg Turnquist + * @author Michael Ruf + * @since 1.2 + */ +public class JacksonMongoSessionConverter extends AbstractMongoSessionConverter { + + private static final Log LOG = LogFactory.getLog(JacksonMongoSessionConverter.class); + + private static final String ATTRS_FIELD_NAME = "attrs."; + + private static final String PRINCIPAL_FIELD_NAME = "principal"; + + private static final String EXPIRE_AT_FIELD_NAME = "expireAt"; + + private final ObjectMapper objectMapper; + + public JacksonMongoSessionConverter() { + this(Collections.emptyList()); + } + + public JacksonMongoSessionConverter(Iterable modules) { + + this.objectMapper = buildObjectMapper(); + this.objectMapper.registerModules(modules); + } + + public JacksonMongoSessionConverter(ObjectMapper objectMapper) { + + Assert.notNull(objectMapper, "ObjectMapper can NOT be null!"); + this.objectMapper = objectMapper; + } + + @Nullable + protected Query getQueryForIndex(String indexName, Object indexValue) { + + if (FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) { + return Query.query(Criteria.where(PRINCIPAL_FIELD_NAME).is(indexValue)); + } + else { + return Query.query(Criteria.where(ATTRS_FIELD_NAME + MongoSession.coverDot(indexName)).is(indexValue)); + } + } + + private ObjectMapper buildObjectMapper() { + + ObjectMapper objectMapper = new ObjectMapper(); + + // serialize fields instead of properties + objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); + objectMapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + + // ignore unresolved fields (mostly 'principal') + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + objectMapper.setPropertyNamingStrategy(new MongoIdNamingStrategy()); + + objectMapper.registerModules(SecurityJackson2Modules.getModules(getClass().getClassLoader())); + objectMapper.addMixIn(MongoSession.class, MongoSessionMixin.class); + objectMapper.addMixIn(HashMap.class, HashMapMixin.class); + + return objectMapper; + } + + @Override + protected DBObject convert(MongoSession source) { + + try { + DBObject dbSession = BasicDBObject.parse(this.objectMapper.writeValueAsString(source)); + + // Override default serialization with proper values. + dbSession.put(PRINCIPAL_FIELD_NAME, extractPrincipal(source)); + dbSession.put(EXPIRE_AT_FIELD_NAME, source.getExpireAt()); + return dbSession; + } + catch (JsonProcessingException ex) { + throw new IllegalStateException("Cannot convert MongoExpiringSession", ex); + } + } + + @Override + @Nullable + protected MongoSession convert(Document source) { + + Date expireAt = (Date) source.remove(EXPIRE_AT_FIELD_NAME); + source.remove("originalSessionId"); + String json = source.toJson(JsonWriterSettings.builder().outputMode(JsonMode.RELAXED).build()); + + try { + MongoSession mongoSession = this.objectMapper.readValue(json, MongoSession.class); + mongoSession.setExpireAt(expireAt); + return mongoSession; + } + catch (IOException ex) { + LOG.error("Error during Mongo Session deserialization", ex); + return null; + } + } + + /** + * Used to whitelist {@link MongoSession} for {@link SecurityJackson2Modules}. + */ + private static class MongoSessionMixin { + + @JsonCreator + MongoSessionMixin(@JsonProperty("_id") String id, + @JsonProperty("intervalSeconds") long maxInactiveIntervalInSeconds) { + } + + } + + /** + * Used to whitelist {@link HashMap} for {@link SecurityJackson2Modules}. + */ + private static class HashMapMixin { + + // Nothing special + + } + + private static class MongoIdNamingStrategy extends PropertyNamingStrategy.PropertyNamingStrategyBase { + + @Override + public String translate(String propertyName) { + + switch (propertyName) { + case "id": + return "_id"; + case "_id": + return "id"; + default: + return propertyName; + } + } + + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JdkMongoSessionConverter.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JdkMongoSessionConverter.java new file mode 100644 index 00000000..ab4e1236 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/JdkMongoSessionConverter.java @@ -0,0 +1,174 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; +import org.bson.Document; +import org.bson.types.Binary; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.serializer.support.DeserializingConverter; +import org.springframework.core.serializer.support.SerializingConverter; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.lang.Nullable; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.Session; +import org.springframework.util.Assert; + +/** + * {@code AbstractMongoSessionConverter} implementation using standard Java serialization. + * + * @author Jakub Kubrynski + * @author Rob Winch + * @author Greg Turnquist + * @since 1.2 + */ +public class JdkMongoSessionConverter extends AbstractMongoSessionConverter { + + private static final String ID = "_id"; + + private static final String CREATION_TIME = "created"; + + private static final String LAST_ACCESSED_TIME = "accessed"; + + private static final String MAX_INTERVAL = "interval"; + + private static final String ATTRIBUTES = "attr"; + + private static final String PRINCIPAL_FIELD_NAME = "principal"; + + private final Converter serializer; + + private final Converter deserializer; + + private Duration maxInactiveInterval; + + public JdkMongoSessionConverter(Duration maxInactiveInterval) { + this(new SerializingConverter(), new DeserializingConverter(), maxInactiveInterval); + } + + public JdkMongoSessionConverter(Converter serializer, Converter deserializer, + Duration maxInactiveInterval) { + + Assert.notNull(serializer, "serializer cannot be null"); + Assert.notNull(deserializer, "deserializer cannot be null"); + Assert.notNull(maxInactiveInterval, "maxInactiveInterval cannot be null"); + + this.serializer = serializer; + this.deserializer = deserializer; + this.maxInactiveInterval = maxInactiveInterval; + } + + @Override + @Nullable + public Query getQueryForIndex(String indexName, Object indexValue) { + + if (FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) { + return Query.query(Criteria.where(PRINCIPAL_FIELD_NAME).is(indexValue)); + } + else { + return null; + } + } + + @Override + protected DBObject convert(MongoSession session) { + + BasicDBObject basicDBObject = new BasicDBObject(); + + basicDBObject.put(ID, session.getId()); + basicDBObject.put(CREATION_TIME, session.getCreationTime()); + basicDBObject.put(LAST_ACCESSED_TIME, session.getLastAccessedTime()); + basicDBObject.put(MAX_INTERVAL, session.getMaxInactiveInterval()); + basicDBObject.put(PRINCIPAL_FIELD_NAME, extractPrincipal(session)); + basicDBObject.put(EXPIRE_AT_FIELD_NAME, session.getExpireAt()); + basicDBObject.put(ATTRIBUTES, serializeAttributes(session)); + + return basicDBObject; + } + + @Override + protected MongoSession convert(Document sessionWrapper) { + + Object maxInterval = sessionWrapper.getOrDefault(MAX_INTERVAL, this.maxInactiveInterval); + + Duration maxIntervalDuration = (maxInterval instanceof Duration) ? (Duration) maxInterval + : Duration.parse(maxInterval.toString()); + + MongoSession session = new MongoSession(sessionWrapper.getString(ID), maxIntervalDuration.getSeconds()); + + Object creationTime = sessionWrapper.get(CREATION_TIME); + if (creationTime instanceof Instant) { + session.setCreationTime(((Instant) creationTime).toEpochMilli()); + } + else if (creationTime instanceof Date) { + session.setCreationTime(((Date) creationTime).getTime()); + } + + Object lastAccessedTime = sessionWrapper.get(LAST_ACCESSED_TIME); + if (lastAccessedTime instanceof Instant) { + session.setLastAccessedTime((Instant) lastAccessedTime); + } + else if (lastAccessedTime instanceof Date) { + session.setLastAccessedTime(Instant.ofEpochMilli(((Date) lastAccessedTime).getTime())); + } + + session.setExpireAt((Date) sessionWrapper.get(EXPIRE_AT_FIELD_NAME)); + + deserializeAttributes(sessionWrapper, session); + + return session; + } + + @Nullable + private byte[] serializeAttributes(Session session) { + + Map attributes = new HashMap<>(); + + for (String attrName : session.getAttributeNames()) { + attributes.put(attrName, session.getAttribute(attrName)); + } + + return this.serializer.convert(attributes); + } + + @SuppressWarnings("unchecked") + private void deserializeAttributes(Document sessionWrapper, Session session) { + + Object sessionAttributes = sessionWrapper.get(ATTRIBUTES); + + byte[] attributesBytes = ((sessionAttributes instanceof Binary) ? ((Binary) sessionAttributes).getData() + : (byte[]) sessionAttributes); + + Map attributes = (Map) this.deserializer.convert(attributesBytes); + + if (attributes != null) { + for (Map.Entry entry : attributes.entrySet()) { + session.setAttribute(entry.getKey(), entry.getValue()); + } + } + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoIndexedSessionRepository.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoIndexedSessionRepository.java new file mode 100644 index 00000000..05020f7c --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoIndexedSessionRepository.java @@ -0,0 +1,192 @@ +/* + * Copyright 2019 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 + * + * https://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.mongo; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.bson.Document; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.lang.Nullable; +import org.springframework.session.FindByIndexNameSessionRepository; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; +import org.springframework.session.events.SessionExpiredEvent; + +/** + * Session repository implementation which stores sessions in Mongo. Uses + * {@link AbstractMongoSessionConverter} to transform session objects from/to native Mongo + * representation ({@code DBObject}). Repository is also responsible for removing expired + * sessions from database. Cleanup is done every minute. + * + * @author Jakub Kubrynski + * @author Greg Turnquist + * @since 2.2.0 + */ +public class MongoIndexedSessionRepository + implements FindByIndexNameSessionRepository, ApplicationEventPublisherAware, InitializingBean { + + /** + * The default time period in seconds in which a session will expire. + */ + public static final int DEFAULT_INACTIVE_INTERVAL = 1800; + + /** + * the default collection name for storing session. + */ + public static final String DEFAULT_COLLECTION_NAME = "sessions"; + + private static final Log logger = LogFactory.getLog(MongoIndexedSessionRepository.class); + + private final MongoOperations mongoOperations; + + private Integer maxInactiveIntervalInSeconds = DEFAULT_INACTIVE_INTERVAL; + + private String collectionName = DEFAULT_COLLECTION_NAME; + + private AbstractMongoSessionConverter mongoSessionConverter = new JdkMongoSessionConverter( + Duration.ofSeconds(this.maxInactiveIntervalInSeconds)); + + private ApplicationEventPublisher eventPublisher; + + public MongoIndexedSessionRepository(MongoOperations mongoOperations) { + this.mongoOperations = mongoOperations; + } + + @Override + public MongoSession createSession() { + + MongoSession session = new MongoSession(); + + if (this.maxInactiveIntervalInSeconds != null) { + session.setMaxInactiveInterval(Duration.ofSeconds(this.maxInactiveIntervalInSeconds)); + } + + publishEvent(new SessionCreatedEvent(this, session)); + + return session; + } + + @Override + public void save(MongoSession session) { + this.mongoOperations + .save(Assert.requireNonNull(MongoSessionUtils.convertToDBObject(this.mongoSessionConverter, session), + "convertToDBObject must not null!"), this.collectionName); + } + + @Override + @Nullable + public MongoSession findById(String id) { + + Document sessionWrapper = findSession(id); + + if (sessionWrapper == null) { + return null; + } + + MongoSession session = MongoSessionUtils.convertToSession(this.mongoSessionConverter, sessionWrapper); + + if (session != null && session.isExpired()) { + publishEvent(new SessionExpiredEvent(this, session)); + deleteById(id); + return null; + } + + return session; + } + + /** + * Currently this repository allows only querying against + * {@code PRINCIPAL_NAME_INDEX_NAME}. + * @param indexName the name if the index (i.e. + * {@link FindByIndexNameSessionRepository#PRINCIPAL_NAME_INDEX_NAME}) + * @param indexValue the value of the index to search for. + * @return sessions map + */ + @Override + public Map findByIndexNameAndIndexValue(String indexName, String indexValue) { + + return Optional.ofNullable(this.mongoSessionConverter.getQueryForIndex(indexName, indexValue)) + .map((query) -> this.mongoOperations.find(query, Document.class, this.collectionName)) + .orElse(Collections.emptyList()).stream() + .map((dbSession) -> MongoSessionUtils.convertToSession(this.mongoSessionConverter, dbSession)) + .collect(Collectors.toMap(MongoSession::getId, (mapSession) -> mapSession)); + } + + @Override + public void deleteById(String id) { + + Optional.ofNullable(findSession(id)).ifPresent((document) -> { + + MongoSession session = MongoSessionUtils.convertToSession(this.mongoSessionConverter, document); + if (session != null) { + publishEvent(new SessionDeletedEvent(this, session)); + } + this.mongoOperations.remove(document, this.collectionName); + }); + } + + @Override + public void afterPropertiesSet() { + + IndexOperations indexOperations = this.mongoOperations.indexOps(this.collectionName); + this.mongoSessionConverter.ensureIndexes(indexOperations); + } + + @Nullable + private Document findSession(String id) { + return this.mongoOperations.findById(id, Document.class, this.collectionName); + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + private void publishEvent(ApplicationEvent event) { + + try { + this.eventPublisher.publishEvent(event); + } + catch (Throwable ex) { + logger.error("Error publishing " + event + ".", ex); + } + } + + public void setMaxInactiveIntervalInSeconds(final Integer maxInactiveIntervalInSeconds) { + this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds; + } + + public void setCollectionName(final String collectionName) { + this.collectionName = collectionName; + } + + public void setMongoSessionConverter(final AbstractMongoSessionConverter mongoSessionConverter) { + this.mongoSessionConverter = mongoSessionConverter; + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoOperationsSessionRepository.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoOperationsSessionRepository.java new file mode 100644 index 00000000..aabd10bf --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoOperationsSessionRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongo; + +import org.springframework.data.mongodb.core.MongoOperations; + +/** + * This {@link org.springframework.session.FindByIndexNameSessionRepository} + * implementation is kept to support backwards compatibility. + * + * @author Rob Winch + * @since 1.2 + * @deprecated since 2.2.0 in favor of {@link MongoIndexedSessionRepository}. + */ +@Deprecated +public class MongoOperationsSessionRepository extends MongoIndexedSessionRepository { + + public MongoOperationsSessionRepository(MongoOperations mongoOperations) { + super(mongoOperations); + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java new file mode 100644 index 00000000..7ab4a65d --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSession.java @@ -0,0 +1,194 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.lang.Nullable; +import org.springframework.session.Session; + +/** + * Session object providing additional information about the datetime of expiration. + * + * @author Jakub Kubrynski + * @author Greg Turnquist + * @since 1.2 + */ +public class MongoSession implements Session { + + /** + * Mongo doesn't support {@literal dot} in field names. We replace it with a very + * rarely used character + */ + private static final char DOT_COVER_CHAR = ''; + + private String id; + + private String originalSessionId; + + private long createdMillis = System.currentTimeMillis(); + + private long accessedMillis; + + private long intervalSeconds; + + private Date expireAt; + + private Map attrs = new HashMap<>(); + + public MongoSession() { + this(MongoIndexedSessionRepository.DEFAULT_INACTIVE_INTERVAL); + } + + public MongoSession(long maxInactiveIntervalInSeconds) { + this(UUID.randomUUID().toString(), maxInactiveIntervalInSeconds); + } + + public MongoSession(String id, long maxInactiveIntervalInSeconds) { + + this.id = id; + this.originalSessionId = id; + this.intervalSeconds = maxInactiveIntervalInSeconds; + setLastAccessedTime(Instant.ofEpochMilli(this.createdMillis)); + } + + static String coverDot(String attributeName) { + return attributeName.replace('.', DOT_COVER_CHAR); + } + + static String uncoverDot(String attributeName) { + return attributeName.replace(DOT_COVER_CHAR, '.'); + } + + @Override + public String changeSessionId() { + + String changedId = UUID.randomUUID().toString(); + this.id = changedId; + return changedId; + } + + @Override + @Nullable + public T getAttribute(String attributeName) { + return (T) this.attrs.get(coverDot(attributeName)); + } + + @Override + public Set getAttributeNames() { + return this.attrs.keySet().stream().map(MongoSession::uncoverDot).collect(Collectors.toSet()); + } + + @Override + public void setAttribute(String attributeName, Object attributeValue) { + + if (attributeValue == null) { + removeAttribute(coverDot(attributeName)); + } + else { + this.attrs.put(coverDot(attributeName), attributeValue); + } + } + + @Override + public void removeAttribute(String attributeName) { + this.attrs.remove(coverDot(attributeName)); + } + + @Override + public Instant getCreationTime() { + return Instant.ofEpochMilli(this.createdMillis); + } + + public void setCreationTime(long created) { + this.createdMillis = created; + } + + @Override + public Instant getLastAccessedTime() { + return Instant.ofEpochMilli(this.accessedMillis); + } + + @Override + public void setLastAccessedTime(Instant lastAccessedTime) { + + this.accessedMillis = lastAccessedTime.toEpochMilli(); + this.expireAt = Date.from(lastAccessedTime.plus(Duration.ofSeconds(this.intervalSeconds))); + } + + @Override + public Duration getMaxInactiveInterval() { + return Duration.ofSeconds(this.intervalSeconds); + } + + @Override + public void setMaxInactiveInterval(Duration interval) { + this.intervalSeconds = interval.getSeconds(); + } + + @Override + public boolean isExpired() { + return this.intervalSeconds >= 0 && new Date().after(this.expireAt); + } + + @Override + public boolean equals(Object o) { + + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MongoSession that = (MongoSession) o; + return Objects.equals(this.id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(this.id); + } + + @Override + public String getId() { + return this.id; + } + + public Date getExpireAt() { + return this.expireAt; + } + + public void setExpireAt(final Date expireAt) { + this.expireAt = expireAt; + } + + boolean hasChangedSessionId() { + return !getId().equals(this.originalSessionId); + } + + String getOriginalSessionId() { + return this.originalSessionId; + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSessionUtils.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSessionUtils.java new file mode 100644 index 00000000..6377cc20 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/MongoSessionUtils.java @@ -0,0 +1,48 @@ +/* + * Copyright 2017 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 + * + * https://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.mongo; + +import com.mongodb.DBObject; +import org.bson.Document; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.lang.Nullable; + +/** + * Utility for MongoSession. + * + * @author Greg Turnquist + */ +public final class MongoSessionUtils { + + private MongoSessionUtils() { + } + + @Nullable + static DBObject convertToDBObject(AbstractMongoSessionConverter mongoSessionConverter, MongoSession session) { + + return (DBObject) mongoSessionConverter.convert(session, TypeDescriptor.valueOf(MongoSession.class), + TypeDescriptor.valueOf(DBObject.class)); + } + + @Nullable + static MongoSession convertToSession(AbstractMongoSessionConverter mongoSessionConverter, Document session) { + + return (MongoSession) mongoSessionConverter.convert(session, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class)); + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoOperationsSessionRepository.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoOperationsSessionRepository.java new file mode 100644 index 00000000..0e4d2b5c --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoOperationsSessionRepository.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017 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 + * + * https://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.mongo; + +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.session.ReactiveSessionRepository; + +/** + * This {@link ReactiveSessionRepository} implementation is kept to support migration to + * {@link ReactiveMongoSessionRepository} in a backwards compatible manner. + * + * @author Greg Turnquist + * @deprecated since 2.2.0 in favor of {@link ReactiveMongoSessionRepository}. + */ +@Deprecated +public class ReactiveMongoOperationsSessionRepository extends ReactiveMongoSessionRepository { + + public ReactiveMongoOperationsSessionRepository(ReactiveMongoOperations mongoOperations) { + super(mongoOperations); + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepository.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepository.java new file mode 100644 index 00000000..153adb7d --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepository.java @@ -0,0 +1,196 @@ +/* + * Copyright 2019 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 + * + * https://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.mongo; + +import java.time.Duration; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.bson.Document; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.events.SessionCreatedEvent; +import org.springframework.session.events.SessionDeletedEvent; + +/** + * A {@link ReactiveSessionRepository} implementation that uses Spring Data MongoDB. + * + * @author Greg Turnquist + * @since 2.2.0 + */ +public class ReactiveMongoSessionRepository + implements ReactiveSessionRepository, ApplicationEventPublisherAware, InitializingBean { + + /** + * The default time period in seconds in which a session will expire. + */ + public static final int DEFAULT_INACTIVE_INTERVAL = 1800; + + /** + * The default collection name for storing session. + */ + public static final String DEFAULT_COLLECTION_NAME = "sessions"; + + private static final Log logger = LogFactory.getLog(ReactiveMongoSessionRepository.class); + + private final ReactiveMongoOperations mongoOperations; + + private Integer maxInactiveIntervalInSeconds = DEFAULT_INACTIVE_INTERVAL; + + private String collectionName = DEFAULT_COLLECTION_NAME; + + private AbstractMongoSessionConverter mongoSessionConverter = new JdkMongoSessionConverter( + Duration.ofSeconds(this.maxInactiveIntervalInSeconds)); + + private MongoOperations blockingMongoOperations; + + private ApplicationEventPublisher eventPublisher; + + public ReactiveMongoSessionRepository(ReactiveMongoOperations mongoOperations) { + this.mongoOperations = mongoOperations; + } + + /** + * Creates a new {@link MongoSession} that is capable of being persisted by this + * {@link ReactiveSessionRepository}. + *

+ * This allows optimizations and customizations in how the {@link MongoSession} is + * persisted. For example, the implementation returned might keep track of the changes + * ensuring that only the delta needs to be persisted on a save. + *

+ * @return a new {@link MongoSession} that is capable of being persisted by this + * {@link ReactiveSessionRepository} + */ + @Override + public Mono createSession() { + + return Mono.justOrEmpty(this.maxInactiveIntervalInSeconds) // + .map(MongoSession::new) // + .doOnNext((mongoSession) -> publishEvent(new SessionCreatedEvent(this, mongoSession))) // + .switchIfEmpty(Mono.just(new MongoSession())); + } + + @Override + public Mono save(MongoSession session) { + + return Mono // + .justOrEmpty(MongoSessionUtils.convertToDBObject(this.mongoSessionConverter, session)) // + .flatMap((dbObject) -> { + if (session.hasChangedSessionId()) { + + return this.mongoOperations + .remove(Query.query(Criteria.where("_id").is(session.getOriginalSessionId())), + this.collectionName) // + .then(this.mongoOperations.save(dbObject, this.collectionName)); + } + else { + + return this.mongoOperations.save(dbObject, this.collectionName); + } + }) // + .then(); + } + + @Override + public Mono findById(String id) { + + return findSession(id) // + .map((document) -> MongoSessionUtils.convertToSession(this.mongoSessionConverter, document)) // + .filter((mongoSession) -> !mongoSession.isExpired()) // + .switchIfEmpty(Mono.defer(() -> this.deleteById(id).then(Mono.empty()))); + } + + @Override + public Mono deleteById(String id) { + + return findSession(id) // + .flatMap((document) -> this.mongoOperations.remove(document, this.collectionName) // + .then(Mono.just(document))) // + .map((document) -> MongoSessionUtils.convertToSession(this.mongoSessionConverter, document)) // + .doOnNext((mongoSession) -> publishEvent(new SessionDeletedEvent(this, mongoSession))) // + .then(); + } + + /** + * Do not use + * {@link org.springframework.data.mongodb.core.index.ReactiveIndexOperations} to + * ensure indexes exist. Instead, get a blocking {@link IndexOperations} and use that + * instead, if possible. + */ + @Override + public void afterPropertiesSet() { + + if (this.blockingMongoOperations != null) { + + IndexOperations indexOperations = this.blockingMongoOperations.indexOps(this.collectionName); + this.mongoSessionConverter.ensureIndexes(indexOperations); + } + } + + private Mono findSession(String id) { + return this.mongoOperations.findById(id, Document.class, this.collectionName); + } + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + private void publishEvent(ApplicationEvent event) { + + try { + this.eventPublisher.publishEvent(event); + } + catch (Throwable ex) { + logger.error("Error publishing " + event + ".", ex); + } + } + + public Integer getMaxInactiveIntervalInSeconds() { + return this.maxInactiveIntervalInSeconds; + } + + public void setMaxInactiveIntervalInSeconds(final Integer maxInactiveIntervalInSeconds) { + this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds; + } + + public String getCollectionName() { + return this.collectionName; + } + + public void setCollectionName(final String collectionName) { + this.collectionName = collectionName; + } + + public void setMongoSessionConverter(final AbstractMongoSessionConverter mongoSessionConverter) { + this.mongoSessionConverter = mongoSessionConverter; + } + + public void setBlockingMongoOperations(final MongoOperations blockingMongoOperations) { + this.blockingMongoOperations = blockingMongoOperations; + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/EnableMongoHttpSession.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/EnableMongoHttpSession.java new file mode 100644 index 00000000..ec34f117 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/EnableMongoHttpSession.java @@ -0,0 +1,69 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo.config.annotation.web.http; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.session.data.mongo.MongoIndexedSessionRepository; + +/** + * Add this annotation to a {@code @Configuration} class to expose the + * SessionRepositoryFilter as a bean named "springSessionRepositoryFilter" and backed by + * Mongo. Use {@code collectionName} to change default name of the collection used to + * store sessions. + * + *
+ * 
+ * {@literal @EnableMongoHttpSession}
+ * public class MongoHttpSessionConfig {
+ *
+ *     {@literal @Bean}
+ *     public MongoOperations mongoOperations() throws UnknownHostException {
+ *         return new MongoTemplate(new MongoClient(), "databaseName");
+ *     }
+ *
+ * }
+ *  
+ * + * @author Jakub Kubrynski + * @since 1.2 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@Import(MongoHttpSessionConfiguration.class) +@Configuration(proxyBeanMethods = false) +public @interface EnableMongoHttpSession { + + /** + * The maximum time a session will be kept if it is inactive. + * @return default max inactive interval in seconds + */ + int maxInactiveIntervalInSeconds() default MongoIndexedSessionRepository.DEFAULT_INACTIVE_INTERVAL; + + /** + * The collection name to use. + * @return name of the collection to store session + */ + String collectionName() default MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME; + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfiguration.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfiguration.java new file mode 100644 index 00000000..ff408198 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfiguration.java @@ -0,0 +1,157 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo.config.annotation.web.http; + +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.EmbeddedValueResolverAware; +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.serializer.support.DeserializingConverter; +import org.springframework.core.serializer.support.SerializingConverter; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.session.IndexResolver; +import org.springframework.session.config.SessionRepositoryCustomizer; +import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration; +import org.springframework.session.data.mongo.AbstractMongoSessionConverter; +import org.springframework.session.data.mongo.JdkMongoSessionConverter; +import org.springframework.session.data.mongo.MongoIndexedSessionRepository; +import org.springframework.session.data.mongo.MongoSession; +import org.springframework.util.StringUtils; +import org.springframework.util.StringValueResolver; + +/** + * Configuration class registering {@code MongoSessionRepository} bean. To import this + * configuration use {@link EnableMongoHttpSession} annotation. + * + * @author Jakub Kubrynski + * @author Eddú Meléndez + * @since 1.2 + */ +@Configuration(proxyBeanMethods = false) +public class MongoHttpSessionConfiguration extends SpringHttpSessionConfiguration + implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware { + + private AbstractMongoSessionConverter mongoSessionConverter; + + private Integer maxInactiveIntervalInSeconds; + + private String collectionName; + + private StringValueResolver embeddedValueResolver; + + private List> sessionRepositoryCustomizers; + + private ClassLoader classLoader; + + private IndexResolver indexResolver; + + @Bean + public MongoIndexedSessionRepository mongoSessionRepository(MongoOperations mongoOperations) { + + MongoIndexedSessionRepository repository = new MongoIndexedSessionRepository(mongoOperations); + repository.setMaxInactiveIntervalInSeconds(this.maxInactiveIntervalInSeconds); + + if (this.mongoSessionConverter != null) { + repository.setMongoSessionConverter(this.mongoSessionConverter); + + if (this.indexResolver != null) { + this.mongoSessionConverter.setIndexResolver(this.indexResolver); + } + } + else { + JdkMongoSessionConverter mongoSessionConverter = new JdkMongoSessionConverter(new SerializingConverter(), + new DeserializingConverter(this.classLoader), + Duration.ofSeconds(MongoIndexedSessionRepository.DEFAULT_INACTIVE_INTERVAL)); + + if (this.indexResolver != null) { + mongoSessionConverter.setIndexResolver(this.indexResolver); + } + + repository.setMongoSessionConverter(mongoSessionConverter); + } + + if (StringUtils.hasText(this.collectionName)) { + repository.setCollectionName(this.collectionName); + } + + this.sessionRepositoryCustomizers + .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(repository)); + + return repository; + } + + public void setCollectionName(String collectionName) { + this.collectionName = collectionName; + } + + public void setMaxInactiveIntervalInSeconds(Integer maxInactiveIntervalInSeconds) { + this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds; + } + + public void setImportMetadata(AnnotationMetadata importMetadata) { + + AnnotationAttributes attributes = AnnotationAttributes + .fromMap(importMetadata.getAnnotationAttributes(EnableMongoHttpSession.class.getName())); + + if (attributes != null) { + this.maxInactiveIntervalInSeconds = attributes.getNumber("maxInactiveIntervalInSeconds"); + } + else { + this.maxInactiveIntervalInSeconds = MongoIndexedSessionRepository.DEFAULT_INACTIVE_INTERVAL; + } + + String collectionNameValue = (attributes != null) ? attributes.getString("collectionName") : ""; + if (StringUtils.hasText(collectionNameValue)) { + this.collectionName = this.embeddedValueResolver.resolveStringValue(collectionNameValue); + } + } + + @Autowired(required = false) + public void setMongoSessionConverter(AbstractMongoSessionConverter mongoSessionConverter) { + this.mongoSessionConverter = mongoSessionConverter; + } + + @Autowired(required = false) + public void setSessionRepositoryCustomizers( + ObjectProvider> sessionRepositoryCustomizers) { + this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList()); + } + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.embeddedValueResolver = resolver; + } + + @Autowired(required = false) + public void setIndexResolver(IndexResolver indexResolver) { + this.indexResolver = indexResolver; + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/EnableMongoWebSession.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/EnableMongoWebSession.java new file mode 100644 index 00000000..7b65d1f5 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/EnableMongoWebSession.java @@ -0,0 +1,69 @@ +/* + * Copyright 2017 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 + * + * https://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.mongo.config.annotation.web.reactive; + +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.data.mongo.ReactiveMongoSessionRepository; + +/** + * Add this annotation to a {@code @Configuration} class to configure a MongoDB-based + * {@code WebSessionManager} for a WebFlux application. This annotation assumes a + * {@code ReactorMongoOperations} is defined somewhere in the application context. If not, + * it will fail with a clear error messages. For example: + * + *
+ * 
+ * {@literal @Configuration}
+ * {@literal @EnableMongoWebSession}
+ * public class SpringWebFluxConfig {
+ *
+ *     {@literal @Bean}
+ *     public ReactorMongoOperations operations() {
+ *         return new MaReactorMongoOperations(...);
+ *     }
+ *
+ * }
+ *  
+ * + * @author Greg Turnquist + * @author Vedran Pavić + * @since 2.0 + */ +@Retention(java.lang.annotation.RetentionPolicy.RUNTIME) +@Target({ java.lang.annotation.ElementType.TYPE }) +@Documented +@Import(ReactiveMongoWebSessionConfiguration.class) +@Configuration(proxyBeanMethods = false) +public @interface EnableMongoWebSession { + + /** + * The maximum time a session will be kept if it is inactive. + * @return default max inactive interval in seconds + */ + int maxInactiveIntervalInSeconds() default ReactiveMongoSessionRepository.DEFAULT_INACTIVE_INTERVAL; + + /** + * The collection name to use. + * @return name of the collection to store session + */ + String collectionName() default ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME; + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfiguration.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfiguration.java new file mode 100644 index 00000000..4d5f2bd3 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfiguration.java @@ -0,0 +1,178 @@ +/* + * Copyright 2017 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 + * + * https://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.mongo.config.annotation.web.reactive; + +import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.EmbeddedValueResolverAware; +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.serializer.support.DeserializingConverter; +import org.springframework.core.serializer.support.SerializingConverter; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.session.IndexResolver; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.config.annotation.web.server.SpringWebSessionConfiguration; +import org.springframework.session.data.mongo.AbstractMongoSessionConverter; +import org.springframework.session.data.mongo.JdkMongoSessionConverter; +import org.springframework.session.data.mongo.MongoSession; +import org.springframework.session.data.mongo.ReactiveMongoSessionRepository; +import org.springframework.util.StringUtils; +import org.springframework.util.StringValueResolver; + +/** + * Configure a {@link ReactiveMongoSessionRepository} using a provided + * {@link ReactiveMongoOperations}. + * + * @author Greg Turnquist + * @author Vedran Pavić + */ +@Configuration(proxyBeanMethods = false) +public class ReactiveMongoWebSessionConfiguration extends SpringWebSessionConfiguration + implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware { + + private AbstractMongoSessionConverter mongoSessionConverter; + + private Integer maxInactiveIntervalInSeconds; + + private String collectionName; + + private StringValueResolver embeddedValueResolver; + + private List> sessionRepositoryCustomizers; + + @Autowired(required = false) + private MongoOperations mongoOperations; + + private ClassLoader classLoader; + + private IndexResolver indexResolver; + + @Bean + public ReactiveMongoSessionRepository reactiveMongoSessionRepository(ReactiveMongoOperations operations) { + + ReactiveMongoSessionRepository repository = new ReactiveMongoSessionRepository(operations); + + if (this.mongoSessionConverter != null) { + repository.setMongoSessionConverter(this.mongoSessionConverter); + + if (this.indexResolver != null) { + this.mongoSessionConverter.setIndexResolver(this.indexResolver); + } + + } + else { + JdkMongoSessionConverter mongoSessionConverter = new JdkMongoSessionConverter(new SerializingConverter(), + new DeserializingConverter(this.classLoader), + Duration.ofSeconds(ReactiveMongoSessionRepository.DEFAULT_INACTIVE_INTERVAL)); + + if (this.indexResolver != null) { + mongoSessionConverter.setIndexResolver(this.indexResolver); + } + + repository.setMongoSessionConverter(mongoSessionConverter); + } + + if (this.maxInactiveIntervalInSeconds != null) { + repository.setMaxInactiveIntervalInSeconds(this.maxInactiveIntervalInSeconds); + } + + if (this.collectionName != null) { + repository.setCollectionName(this.collectionName); + } + + if (this.mongoOperations != null) { + repository.setBlockingMongoOperations(this.mongoOperations); + } + + this.sessionRepositoryCustomizers + .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(repository)); + + return repository; + } + + @Autowired(required = false) + public void setMongoSessionConverter(AbstractMongoSessionConverter mongoSessionConverter) { + this.mongoSessionConverter = mongoSessionConverter; + } + + @Override + public void setImportMetadata(AnnotationMetadata importMetadata) { + + AnnotationAttributes attributes = AnnotationAttributes + .fromMap(importMetadata.getAnnotationAttributes(EnableMongoWebSession.class.getName())); + + if (attributes != null) { + this.maxInactiveIntervalInSeconds = attributes.getNumber("maxInactiveIntervalInSeconds"); + } + else { + this.maxInactiveIntervalInSeconds = ReactiveMongoSessionRepository.DEFAULT_INACTIVE_INTERVAL; + } + + String collectionNameValue = (attributes != null) ? attributes.getString("collectionName") : ""; + if (StringUtils.hasText(collectionNameValue)) { + this.collectionName = this.embeddedValueResolver.resolveStringValue(collectionNameValue); + } + + } + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + @Override + public void setEmbeddedValueResolver(StringValueResolver embeddedValueResolver) { + this.embeddedValueResolver = embeddedValueResolver; + } + + public Integer getMaxInactiveIntervalInSeconds() { + return this.maxInactiveIntervalInSeconds; + } + + public void setMaxInactiveIntervalInSeconds(Integer maxInactiveIntervalInSeconds) { + this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds; + } + + public String getCollectionName() { + return this.collectionName; + } + + public void setCollectionName(String collectionName) { + this.collectionName = collectionName; + } + + @Autowired(required = false) + public void setSessionRepositoryCustomizers( + ObjectProvider> sessionRepositoryCustomizers) { + this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList()); + } + + @Autowired(required = false) + public void setIndexResolver(IndexResolver indexResolver) { + this.indexResolver = indexResolver; + } + +} diff --git a/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/package-info.java b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/package-info.java new file mode 100644 index 00000000..5bba45e7 --- /dev/null +++ b/spring-session-data-mongodb/src/main/java/org/springframework/session/data/mongo/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2019 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 + * + * https://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. + */ + +/** + * Spring Session MongoDB support. + * + * @author Greg Turnquist + */ +@NonNullApi +package org.springframework.session.data.mongo; + +import org.springframework.lang.NonNullApi; diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/AbstractMongoSessionConverterTest.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/AbstractMongoSessionConverterTest.java new file mode 100644 index 00000000..a8f9eb09 --- /dev/null +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/AbstractMongoSessionConverterTest.java @@ -0,0 +1,141 @@ +/* + * Copyright 2017 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 + * + * https://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.mongo; + +import java.time.Duration; + +import com.mongodb.DBObject; +import org.bson.Document; +import org.junit.jupiter.api.Test; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.session.FindByIndexNameSessionRepository; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Greg Turnquist + */ +public abstract class AbstractMongoSessionConverterTest { + + abstract AbstractMongoSessionConverter getMongoSessionConverter(); + + @Test + void verifyRoundTripSerialization() throws Exception { + + // given + MongoSession toSerialize = new MongoSession(); + toSerialize.setAttribute("username", "john_the_springer"); + + // when + DBObject dbObject = convertToDBObject(toSerialize); + MongoSession deserialized = convertToSession(dbObject); + + // then + assertThat(deserialized).isEqualToComparingFieldByField(toSerialize); + } + + @Test + void verifyRoundTripSecuritySerialization() { + + // given + MongoSession toSerialize = new MongoSession(); + String principalName = "john_the_springer"; + SecurityContextImpl context = new SecurityContextImpl(); + context.setAuthentication(new UsernamePasswordAuthenticationToken(principalName, null)); + toSerialize.setAttribute("SPRING_SECURITY_CONTEXT", context); + + // when + DBObject serialized = convertToDBObject(toSerialize); + MongoSession deserialized = convertToSession(serialized); + + // then + assertThat(deserialized).isEqualToComparingOnlyGivenFields(toSerialize, "id", "createdMillis", "accessedMillis", + "intervalSeconds", "expireAt"); + + SecurityContextImpl springSecurityContextBefore = toSerialize.getAttribute("SPRING_SECURITY_CONTEXT"); + SecurityContextImpl springSecurityContextAfter = deserialized.getAttribute("SPRING_SECURITY_CONTEXT"); + + assertThat(springSecurityContextBefore).isEqualToComparingOnlyGivenFields(springSecurityContextAfter, + "authentication.principal", "authentication.authorities", "authentication.authenticated"); + assertThat(springSecurityContextAfter.getAuthentication().getPrincipal()).isEqualTo("john_the_springer"); + assertThat(springSecurityContextAfter.getAuthentication().getCredentials()).isNull(); + } + + @Test + void shouldExtractPrincipalNameFromAttributes() throws Exception { + + // given + MongoSession toSerialize = new MongoSession(); + String principalName = "john_the_springer"; + toSerialize.setAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principalName); + + // when + DBObject dbObject = convertToDBObject(toSerialize); + + // then + assertThat(dbObject.get("principal")).isEqualTo(principalName); + } + + @Test + void shouldExtractPrincipalNameFromAuthentication() throws Exception { + + // given + MongoSession toSerialize = new MongoSession(); + String principalName = "john_the_springer"; + SecurityContextImpl context = new SecurityContextImpl(); + context.setAuthentication(new UsernamePasswordAuthenticationToken(principalName, null)); + toSerialize.setAttribute("SPRING_SECURITY_CONTEXT", context); + + // when + DBObject dbObject = convertToDBObject(toSerialize); + + // then + assertThat(dbObject.get("principal")).isEqualTo(principalName); + } + + @Test + void sessionWrapperWithNoMaxIntervalShouldFallbackToDefaultValues() { + + // given + MongoSession toSerialize = new MongoSession(); + DBObject dbObject = convertToDBObject(toSerialize); + Document document = new Document(dbObject.toMap()); + document.remove("interval"); + + // when + MongoSession convertedSession = getMongoSessionConverter().convert(document); + + // then + assertThat(convertedSession.getMaxInactiveInterval()).isEqualTo(Duration.ofMinutes(30)); + } + + @Nullable + MongoSession convertToSession(DBObject session) { + return (MongoSession) getMongoSessionConverter().convert(session, TypeDescriptor.valueOf(DBObject.class), + TypeDescriptor.valueOf(MongoSession.class)); + } + + @Nullable + DBObject convertToDBObject(MongoSession session) { + return (DBObject) getMongoSessionConverter().convert(session, TypeDescriptor.valueOf(MongoSession.class), + TypeDescriptor.valueOf(DBObject.class)); + } + +} diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JacksonMongoSessionConverterTest.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JacksonMongoSessionConverterTest.java new file mode 100644 index 00000000..3921940f --- /dev/null +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JacksonMongoSessionConverterTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo; + +import java.lang.reflect.Field; +import java.util.Date; +import java.util.HashMap; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.DBObject; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.AssertionsForClassTypes; +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; + +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.util.ReflectionUtils; + +/** + * @author Jakub Kubrynski + * @author Greg Turnquist + */ +public class JacksonMongoSessionConverterTest extends AbstractMongoSessionConverterTest { + + JacksonMongoSessionConverter mongoSessionConverter = new JacksonMongoSessionConverter(); + + @Override + AbstractMongoSessionConverter getMongoSessionConverter() { + return this.mongoSessionConverter; + } + + @Test + void shouldSaveIdField() { + + // given + MongoSession session = new MongoSession(); + + // when + DBObject convert = this.mongoSessionConverter.convert(session); + + // then + AssertionsForClassTypes.assertThat(convert.get("_id")).isEqualTo(session.getId()); + AssertionsForClassTypes.assertThat(convert.get("id")).isNull(); + } + + @Test + void shouldQueryAgainstAttribute() throws Exception { + + // when + Query cart = this.mongoSessionConverter.getQueryForIndex("cart", "my-cart"); + + // then + AssertionsForClassTypes.assertThat(cart.getQueryObject().get("attrs.cart")).isEqualTo("my-cart"); + } + + @Test + void shouldAllowCustomObjectMapper() { + + // given + ObjectMapper myMapper = new ObjectMapper(); + + // when + JacksonMongoSessionConverter converter = new JacksonMongoSessionConverter(myMapper); + + // then + Field objectMapperField = ReflectionUtils.findField(JacksonMongoSessionConverter.class, "objectMapper"); + ReflectionUtils.makeAccessible(objectMapperField); + ObjectMapper converterMapper = (ObjectMapper) ReflectionUtils.getField(objectMapperField, converter); + + AssertionsForClassTypes.assertThat(converterMapper).isEqualTo(myMapper); + } + + @Test + void shouldNotAllowNullObjectMapperToBeInjected() { + + Assertions.assertThatIllegalArgumentException() + .isThrownBy(() -> new JacksonMongoSessionConverter((ObjectMapper) null)); + } + + @Test + void shouldSaveExpireAtAsDate() { + + // given + MongoSession session = new MongoSession(); + + // when + DBObject convert = this.mongoSessionConverter.convert(session); + + // then + AssertionsForClassTypes.assertThat(convert.get("expireAt")).isInstanceOf(Date.class); + AssertionsForClassTypes.assertThat(convert.get("expireAt")).isEqualTo(session.getExpireAt()); + } + + @Test + void shouldLoadExpireAtFromDocument() { + + // given + Date now = new Date(); + HashMap data = new HashMap<>(); + + data.put("expireAt", now); + data.put("@class", MongoSession.class.getName()); + data.put("_id", new ObjectId().toString()); + + Document document = new Document(data); + + // when + MongoSession convertedSession = this.mongoSessionConverter.convert(document); + + // then + AssertionsForClassTypes.assertThat(convertedSession).isNotNull(); + AssertionsForClassTypes.assertThat(convertedSession.getExpireAt()).isEqualTo(now); + } + +} diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JdkMongoSessionConverterTest.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JdkMongoSessionConverterTest.java new file mode 100644 index 00000000..5ed701dc --- /dev/null +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/JdkMongoSessionConverterTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.serializer.support.DeserializingConverter; +import org.springframework.core.serializer.support.SerializingConverter; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * @author Jakub Kubrynski + * @author Rob Winch + * @author Greg Turnquist + */ +public class JdkMongoSessionConverterTest extends AbstractMongoSessionConverterTest { + + Duration inactiveInterval = Duration.ofMinutes(30); + + JdkMongoSessionConverter mongoSessionConverter = new JdkMongoSessionConverter(this.inactiveInterval); + + @Override + AbstractMongoSessionConverter getMongoSessionConverter() { + return this.mongoSessionConverter; + } + + @Test + void constructorNullSerializer() { + assertThatIllegalArgumentException().isThrownBy( + () -> new JdkMongoSessionConverter(null, new DeserializingConverter(), this.inactiveInterval)); + } + + @Test + void constructorNullDeserializer() { + assertThatIllegalArgumentException().isThrownBy( + () -> new JdkMongoSessionConverter(new SerializingConverter(), null, this.inactiveInterval)); + } + +} diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoIndexedSessionRepositoryTest.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoIndexedSessionRepositoryTest.java new file mode 100644 index 00000000..0061744a --- /dev/null +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoIndexedSessionRepositoryTest.java @@ -0,0 +1,225 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongo; + +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.session.FindByIndexNameSessionRepository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.verify; + +/** + * Tests for {@link MongoIndexedSessionRepository}. + * + * @author Jakub Kubrynski + * @author Vedran Pavic + * @author Greg Turnquist + */ +@ExtendWith(MockitoExtension.class) +public class MongoIndexedSessionRepositoryTest { + + @Mock + private AbstractMongoSessionConverter converter; + + @Mock + private MongoOperations mongoOperations; + + private MongoIndexedSessionRepository repository; + + @BeforeEach + void setUp() { + + this.repository = new MongoIndexedSessionRepository(this.mongoOperations); + this.repository.setMongoSessionConverter(this.converter); + } + + @Test + void shouldCreateSession() { + + // when + MongoSession session = this.repository.createSession(); + + // then + assertThat(session.getId()).isNotEmpty(); + assertThat(session.getMaxInactiveInterval().getSeconds()) + .isEqualTo(MongoIndexedSessionRepository.DEFAULT_INACTIVE_INTERVAL); + } + + @Test + void shouldCreateSessionWhenMaxInactiveIntervalNotDefined() { + + // when + this.repository.setMaxInactiveIntervalInSeconds(null); + MongoSession session = this.repository.createSession(); + + // then + assertThat(session.getId()).isNotEmpty(); + assertThat(session.getMaxInactiveInterval().getSeconds()) + .isEqualTo(MongoIndexedSessionRepository.DEFAULT_INACTIVE_INTERVAL); + } + + @Test + void shouldSaveSession() { + + // given + MongoSession session = new MongoSession(); + BasicDBObject dbSession = new BasicDBObject(); + + given(this.converter.convert(session, TypeDescriptor.valueOf(MongoSession.class), + TypeDescriptor.valueOf(DBObject.class))).willReturn(dbSession); + // when + this.repository.save(session); + + // then + verify(this.mongoOperations).save(dbSession, MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME); + } + + @Test + void shouldGetSession() { + + // given + String sessionId = UUID.randomUUID().toString(); + Document sessionDocument = new Document(); + + given(this.mongoOperations.findById(sessionId, Document.class, + MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME)).willReturn(sessionDocument); + + MongoSession session = new MongoSession(); + + given(this.converter.convert(sessionDocument, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))).willReturn(session); + + // when + MongoSession retrievedSession = this.repository.findById(sessionId); + + // then + assertThat(retrievedSession).isEqualTo(session); + } + + @Test + void shouldHandleExpiredSession() { + + // given + String sessionId = UUID.randomUUID().toString(); + Document sessionDocument = new Document(); + + given(this.mongoOperations.findById(sessionId, Document.class, + MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME)).willReturn(sessionDocument); + + MongoSession session = mock(MongoSession.class); + + given(session.isExpired()).willReturn(true); + given(this.converter.convert(sessionDocument, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))).willReturn(session); + given(session.getId()).willReturn("sessionId"); + + // when + this.repository.findById(sessionId); + + // then + verify(this.mongoOperations).remove(any(Document.class), + eq(MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME)); + } + + @Test + void shouldDeleteSession() { + + // given + String sessionId = UUID.randomUUID().toString(); + + Document sessionDocument = new Document(); + sessionDocument.put("id", sessionId); + + MongoSession mongoSession = new MongoSession(sessionId, + MongoIndexedSessionRepository.DEFAULT_INACTIVE_INTERVAL); + + given(this.converter.convert(sessionDocument, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))).willReturn(mongoSession); + given(this.mongoOperations.findById(eq(sessionId), eq(Document.class), + eq(MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME))).willReturn(sessionDocument); + + // when + this.repository.deleteById(sessionId); + + // then + verify(this.mongoOperations).remove(any(Document.class), + eq(MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME)); + } + + @Test + void shouldGetSessionsMapByPrincipal() { + + // given + String principalNameIndexName = FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME; + + Document document = new Document(); + + given(this.converter.getQueryForIndex(anyString(), any(Object.class))).willReturn(mock(Query.class)); + given(this.mongoOperations.find(any(Query.class), eq(Document.class), + eq(MongoIndexedSessionRepository.DEFAULT_COLLECTION_NAME))) + .willReturn(Collections.singletonList(document)); + + String sessionId = UUID.randomUUID().toString(); + + MongoSession session = new MongoSession(sessionId, 1800); + + given(this.converter.convert(document, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))).willReturn(session); + + // when + Map sessionsMap = this.repository.findByIndexNameAndIndexValue(principalNameIndexName, + "john"); + + // then + assertThat(sessionsMap).containsOnlyKeys(sessionId); + assertThat(sessionsMap).containsValues(session); + } + + @Test + void shouldReturnEmptyMapForNotSupportedIndex() { + + // given + String index = "some_not_supported_index_name"; + + // when + Map sessionsMap = this.repository.findByIndexNameAndIndexValue(index, "some_value"); + + // then + assertThat(sessionsMap).isEmpty(); + } + +} diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoSessionTest.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoSessionTest.java new file mode 100644 index 00000000..ccc53313 --- /dev/null +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/MongoSessionTest.java @@ -0,0 +1,42 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongo; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rob Winch + * @author Greg Turnquist + */ +public class MongoSessionTest { + + @Test + void isExpiredWhenIntervalNegativeThenFalse() { + + MongoSession session = new MongoSession(); + session.setMaxInactiveInterval(Duration.ofSeconds(-1)); + session.setLastAccessedTime(Instant.ofEpochMilli(0L)); + + assertThat(session.isExpired()).isFalse(); + } + +} diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepositoryTest.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepositoryTest.java new file mode 100644 index 00000000..3dbfb6c2 --- /dev/null +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepositoryTest.java @@ -0,0 +1,229 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongo; + +import java.util.UUID; + +import com.mongodb.BasicDBObject; +import com.mongodb.DBObject; +import com.mongodb.client.result.DeleteResult; +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.session.events.SessionDeletedEvent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.times; +import static org.mockito.BDDMockito.verify; + +/** + * Tests for {@link ReactiveMongoSessionRepository}. + * + * @author Jakub Kubrynski + * @author Vedran Pavic + * @author Greg Turnquist + */ +@ExtendWith(MockitoExtension.class) +public class ReactiveMongoSessionRepositoryTest { + + @Mock + private AbstractMongoSessionConverter converter; + + @Mock + private ReactiveMongoOperations mongoOperations; + + @Mock + private MongoOperations blockingMongoOperations; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private ReactiveMongoSessionRepository repository; + + @BeforeEach + void setUp() { + + this.repository = new ReactiveMongoSessionRepository(this.mongoOperations); + this.repository.setMongoSessionConverter(this.converter); + this.repository.setApplicationEventPublisher(this.eventPublisher); + } + + @Test + void shouldCreateSession() { + + this.repository.createSession() // + .as(StepVerifier::create) // + .expectNextMatches((mongoSession) -> { + assertThat(mongoSession.getId()).isNotEmpty(); + assertThat(mongoSession.getMaxInactiveInterval().getSeconds()) + .isEqualTo(ReactiveMongoSessionRepository.DEFAULT_INACTIVE_INTERVAL); + return true; + }) // + .verifyComplete(); + } + + @Test + void shouldCreateSessionWhenMaxInactiveIntervalNotDefined() { + + // when + this.repository.setMaxInactiveIntervalInSeconds(null); + + // then + this.repository.createSession() // + .as(StepVerifier::create) // + .expectNextMatches((mongoSession) -> { + assertThat(mongoSession.getId()).isNotEmpty(); + assertThat(mongoSession.getMaxInactiveInterval().getSeconds()) + .isEqualTo(ReactiveMongoSessionRepository.DEFAULT_INACTIVE_INTERVAL); + return true; + }) // + .verifyComplete(); + } + + @Test + void shouldSaveSession() { + + // given + MongoSession session = new MongoSession(); + BasicDBObject dbSession = new BasicDBObject(); + + given(this.converter.convert(session, TypeDescriptor.valueOf(MongoSession.class), + TypeDescriptor.valueOf(DBObject.class))).willReturn(dbSession); + + given(this.mongoOperations.save(dbSession, "sessions")).willReturn(Mono.just(dbSession)); + + // when + this.repository.save(session) // + .as(StepVerifier::create) // + .verifyComplete(); + + verify(this.mongoOperations).save(dbSession, ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME); + } + + @Test + void shouldGetSession() { + + // given + String sessionId = UUID.randomUUID().toString(); + Document sessionDocument = new Document(); + + given(this.mongoOperations.findById(sessionId, Document.class, + ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME)).willReturn(Mono.just(sessionDocument)); + + MongoSession session = new MongoSession(); + + given(this.converter.convert(sessionDocument, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))).willReturn(session); + + // when + this.repository.findById(sessionId) // + .as(StepVerifier::create) // + .expectNext(session) // + .verifyComplete(); + } + + @Test + void shouldHandleExpiredSession() { + + // given + String sessionId = UUID.randomUUID().toString(); + Document sessionDocument = new Document(); + + given(this.mongoOperations.findById(sessionId, Document.class, + ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME)).willReturn(Mono.just(sessionDocument)); + + given(this.mongoOperations.remove(sessionDocument, ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME)) + .willReturn(Mono.just(DeleteResult.acknowledged(1))); + + MongoSession session = mock(MongoSession.class); + + given(session.isExpired()).willReturn(true); + given(this.converter.convert(sessionDocument, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))).willReturn(session); + + // when + this.repository.findById(sessionId) // + .as(StepVerifier::create) // + .verifyComplete(); + + // then + verify(this.mongoOperations).remove(any(Document.class), + eq(ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME)); + } + + @Test + void shouldDeleteSession() { + + // given + String sessionId = UUID.randomUUID().toString(); + Document sessionDocument = new Document(); + + given(this.mongoOperations.findById(sessionId, Document.class, + ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME)).willReturn(Mono.just(sessionDocument)); + + given(this.mongoOperations.remove(sessionDocument, "sessions")) + .willReturn(Mono.just(DeleteResult.acknowledged(1))); + + MongoSession session = mock(MongoSession.class); + + given(this.converter.convert(sessionDocument, TypeDescriptor.valueOf(Document.class), + TypeDescriptor.valueOf(MongoSession.class))).willReturn(session); + + // when + this.repository.deleteById(sessionId) // + .as(StepVerifier::create) // + .verifyComplete(); + + verify(this.mongoOperations).remove(any(Document.class), + eq(ReactiveMongoSessionRepository.DEFAULT_COLLECTION_NAME)); + + verify(this.eventPublisher).publishEvent(any(SessionDeletedEvent.class)); + } + + @Test + void shouldInvokeMethodToCreateIndexesImperatively() { + + // given + IndexOperations indexOperations = mock(IndexOperations.class); + given(this.blockingMongoOperations.indexOps((String) any())).willReturn(indexOperations); + + this.repository.setBlockingMongoOperations(this.blockingMongoOperations); + + // when + this.repository.afterPropertiesSet(); + + // then + verify(this.blockingMongoOperations, times(1)).indexOps((String) any()); + verify(this.converter, times(1)).ensureIndexes(indexOperations); + } + +} diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfigurationTest.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfigurationTest.java new file mode 100644 index 00000000..1ac92d0f --- /dev/null +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/http/MongoHttpSessionConfigurationTest.java @@ -0,0 +1,334 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongo.config.annotation.web.http; + +import java.net.UnknownHostException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.UnsatisfiedDependencyException; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.session.IndexResolver; +import org.springframework.session.config.SessionRepositoryCustomizer; +import org.springframework.session.data.mongo.AbstractMongoSessionConverter; +import org.springframework.session.data.mongo.JacksonMongoSessionConverter; +import org.springframework.session.data.mongo.MongoIndexedSessionRepository; +import org.springframework.session.data.mongo.MongoSession; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; + +/** + * Tests for {@link MongoHttpSessionConfiguration}. + * + * @author Eddú Meléndez + * @author Vedran Pavic + */ +public class MongoHttpSessionConfigurationTest { + + private static final String COLLECTION_NAME = "testSessions"; + + private static final int MAX_INACTIVE_INTERVAL_IN_SECONDS = 600; + + private AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + + @AfterEach + void after() { + + if (this.context != null) { + this.context.close(); + } + } + + @Test + void noMongoOperationsConfiguration() { + + assertThatExceptionOfType(UnsatisfiedDependencyException.class) + .isThrownBy(() -> registerAndRefresh(EmptyConfiguration.class)) + .withMessageContaining("mongoSessionRepository"); + } + + @Test + void defaultConfiguration() { + + registerAndRefresh(DefaultConfiguration.class); + + assertThat(this.context.getBean(MongoIndexedSessionRepository.class)).isNotNull(); + } + + @Test + void customCollectionName() { + + registerAndRefresh(CustomCollectionNameConfiguration.class); + + MongoIndexedSessionRepository repository = this.context.getBean(MongoIndexedSessionRepository.class); + + assertThat(repository).isNotNull(); + assertThat(ReflectionTestUtils.getField(repository, "collectionName")).isEqualTo(COLLECTION_NAME); + } + + @Test + void setCustomCollectionName() { + + registerAndRefresh(CustomCollectionNameSetConfiguration.class); + + MongoHttpSessionConfiguration session = this.context.getBean(MongoHttpSessionConfiguration.class); + + assertThat(session).isNotNull(); + assertThat(ReflectionTestUtils.getField(session, "collectionName")).isEqualTo(COLLECTION_NAME); + } + + @Test + void customMaxInactiveIntervalInSeconds() { + + registerAndRefresh(CustomMaxInactiveIntervalInSecondsConfiguration.class); + + MongoIndexedSessionRepository repository = this.context.getBean(MongoIndexedSessionRepository.class); + + assertThat(repository).isNotNull(); + assertThat(ReflectionTestUtils.getField(repository, "maxInactiveIntervalInSeconds")) + .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + @Test + void setCustomMaxInactiveIntervalInSeconds() { + + registerAndRefresh(CustomMaxInactiveIntervalInSecondsSetConfiguration.class); + + MongoIndexedSessionRepository repository = this.context.getBean(MongoIndexedSessionRepository.class); + + assertThat(repository).isNotNull(); + assertThat(ReflectionTestUtils.getField(repository, "maxInactiveIntervalInSeconds")) + .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + @Test + void setCustomSessionConverterConfiguration() { + + registerAndRefresh(CustomSessionConverterConfiguration.class); + + MongoIndexedSessionRepository repository = this.context.getBean(MongoIndexedSessionRepository.class); + AbstractMongoSessionConverter mongoSessionConverter = this.context.getBean(AbstractMongoSessionConverter.class); + + assertThat(repository).isNotNull(); + assertThat(mongoSessionConverter).isNotNull(); + assertThat(ReflectionTestUtils.getField(repository, "mongoSessionConverter")).isEqualTo(mongoSessionConverter); + } + + @Test + void resolveCollectionNameByPropertyPlaceholder() { + + this.context + .setEnvironment(new MockEnvironment().withProperty("session.mongo.collectionName", COLLECTION_NAME)); + registerAndRefresh(CustomMongoJdbcSessionConfiguration.class); + + MongoHttpSessionConfiguration configuration = this.context.getBean(MongoHttpSessionConfiguration.class); + + assertThat(ReflectionTestUtils.getField(configuration, "collectionName")).isEqualTo(COLLECTION_NAME); + } + + @Test + void sessionRepositoryCustomizer() { + + registerAndRefresh(MongoConfiguration.class, SessionRepositoryCustomizerConfiguration.class); + + MongoIndexedSessionRepository sessionRepository = this.context.getBean(MongoIndexedSessionRepository.class); + + assertThat(sessionRepository).hasFieldOrPropertyWithValue("maxInactiveIntervalInSeconds", 10000); + } + + @Test + void customIndexResolverConfigurationWithDefaultMongoSessionConverter() { + + registerAndRefresh(MongoConfiguration.class, + CustomIndexResolverConfigurationWithDefaultMongoSessionConverter.class); + + MongoIndexedSessionRepository repository = this.context.getBean(MongoIndexedSessionRepository.class); + IndexResolver indexResolver = this.context.getBean(IndexResolver.class); + + assertThat(repository).isNotNull(); + assertThat(indexResolver).isNotNull(); + assertThat(repository).extracting("mongoSessionConverter").hasFieldOrPropertyWithValue("indexResolver", + indexResolver); + } + + @Test + void customIndexResolverConfigurationWithProvidedMongoSessionConverter() { + + registerAndRefresh(MongoConfiguration.class, + CustomIndexResolverConfigurationWithProvidedMongoSessionConverter.class); + + MongoIndexedSessionRepository repository = this.context.getBean(MongoIndexedSessionRepository.class); + IndexResolver indexResolver = this.context.getBean(IndexResolver.class); + + assertThat(repository).isNotNull(); + assertThat(indexResolver).isNotNull(); + assertThat(repository).extracting("mongoSessionConverter").hasFieldOrPropertyWithValue("indexResolver", + indexResolver); + } + + private void registerAndRefresh(Class... annotatedClasses) { + + this.context.register(annotatedClasses); + this.context.refresh(); + } + + @Configuration + @EnableMongoHttpSession + static class EmptyConfiguration { + + } + + static class BaseConfiguration { + + @Bean + MongoOperations mongoOperations() throws UnknownHostException { + + MongoOperations mongoOperations = mock(MongoOperations.class); + IndexOperations indexOperations = mock(IndexOperations.class); + + given(mongoOperations.indexOps(anyString())).willReturn(indexOperations); + + return mongoOperations; + } + + } + + @Configuration + @EnableMongoHttpSession + static class DefaultConfiguration extends BaseConfiguration { + + } + + @Configuration + static class MongoConfiguration extends BaseConfiguration { + + } + + @Configuration + @EnableMongoHttpSession(collectionName = COLLECTION_NAME) + static class CustomCollectionNameConfiguration extends BaseConfiguration { + + } + + @Configuration + @Import(MongoConfiguration.class) + static class CustomCollectionNameSetConfiguration extends MongoHttpSessionConfiguration { + + CustomCollectionNameSetConfiguration() { + setCollectionName(COLLECTION_NAME); + } + + } + + @Configuration + @EnableMongoHttpSession(maxInactiveIntervalInSeconds = MAX_INACTIVE_INTERVAL_IN_SECONDS) + static class CustomMaxInactiveIntervalInSecondsConfiguration extends BaseConfiguration { + + } + + @Configuration + @Import(MongoConfiguration.class) + static class CustomMaxInactiveIntervalInSecondsSetConfiguration extends MongoHttpSessionConfiguration { + + CustomMaxInactiveIntervalInSecondsSetConfiguration() { + setMaxInactiveIntervalInSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS); + } + + } + + @Configuration + @Import(MongoConfiguration.class) + static class CustomSessionConverterConfiguration extends MongoHttpSessionConfiguration { + + @Bean + AbstractMongoSessionConverter mongoSessionConverter() { + return mock(AbstractMongoSessionConverter.class); + } + + } + + @Configuration + @EnableMongoHttpSession(collectionName = "${session.mongo.collectionName}") + static class CustomMongoJdbcSessionConfiguration extends BaseConfiguration { + + @Bean + PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { + return new PropertySourcesPlaceholderConfigurer(); + } + + } + + @EnableMongoHttpSession + static class SessionRepositoryCustomizerConfiguration { + + @Bean + @Order(0) + SessionRepositoryCustomizer sessionRepositoryCustomizerOne() { + return (sessionRepository) -> sessionRepository.setMaxInactiveIntervalInSeconds(0); + } + + @Bean + @Order(1) + SessionRepositoryCustomizer sessionRepositoryCustomizerTwo() { + return (sessionRepository) -> sessionRepository.setMaxInactiveIntervalInSeconds(10000); + } + + } + + @Configuration + @EnableMongoHttpSession + static class CustomIndexResolverConfigurationWithDefaultMongoSessionConverter { + + @Bean + @SuppressWarnings("unchecked") + IndexResolver indexResolver() { + return mock(IndexResolver.class); + } + + } + + @Configuration + @EnableMongoHttpSession + static class CustomIndexResolverConfigurationWithProvidedMongoSessionConverter { + + @Bean + AbstractMongoSessionConverter mongoSessionConverter() { + return new JacksonMongoSessionConverter(); + } + + @Bean + @SuppressWarnings("unchecked") + IndexResolver indexResolver() { + return mock(IndexResolver.class); + } + + } + +} diff --git a/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfigurationTest.java b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfigurationTest.java new file mode 100644 index 00000000..a93d3f63 --- /dev/null +++ b/spring-session-data-mongodb/src/test/java/org/springframework/session/data/mongo/config/annotation/web/reactive/ReactiveMongoWebSessionConfigurationTest.java @@ -0,0 +1,385 @@ +/* + * Copyright 2017 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 + * + * https://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.mongo.config.annotation.web.reactive; + +import java.lang.reflect.Field; +import java.util.Collections; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.UnsatisfiedDependencyException; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.session.IndexResolver; +import org.springframework.session.ReactiveSessionRepository; +import org.springframework.session.config.ReactiveSessionRepositoryCustomizer; +import org.springframework.session.config.annotation.web.server.EnableSpringWebSession; +import org.springframework.session.data.mongo.AbstractMongoSessionConverter; +import org.springframework.session.data.mongo.JacksonMongoSessionConverter; +import org.springframework.session.data.mongo.JdkMongoSessionConverter; +import org.springframework.session.data.mongo.MongoSession; +import org.springframework.session.data.mongo.ReactiveMongoSessionRepository; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import org.springframework.web.server.session.WebSessionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.times; +import static org.mockito.BDDMockito.verify; + +/** + * Verify various configurations through {@link EnableSpringWebSession}. + * + * @author Greg Turnquist + * @author Vedran Pavić + */ +public class ReactiveMongoWebSessionConfigurationTest { + + private AnnotationConfigApplicationContext context; + + @AfterEach + void tearDown() { + + if (this.context != null) { + this.context.close(); + } + } + + @Test + void enableSpringWebSessionConfiguresThings() { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(GoodConfig.class); + this.context.refresh(); + + WebSessionManager webSessionManagerFoundByType = this.context.getBean(WebSessionManager.class); + Object webSessionManagerFoundByName = this.context.getBean(WebHttpHandlerBuilder.WEB_SESSION_MANAGER_BEAN_NAME); + + assertThat(webSessionManagerFoundByType).isNotNull(); + assertThat(webSessionManagerFoundByName).isNotNull(); + assertThat(webSessionManagerFoundByType).isEqualTo(webSessionManagerFoundByName); + + assertThat(this.context.getBean(ReactiveSessionRepository.class)).isNotNull(); + } + + @Test + void missingReactorSessionRepositoryBreaksAppContext() { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(BadConfig.class); + + assertThatExceptionOfType(UnsatisfiedDependencyException.class).isThrownBy(this.context::refresh) + .withMessageContaining("Error creating bean with name 'reactiveMongoSessionRepository'") + .withMessageContaining( + "No qualifying bean of type '" + ReactiveMongoOperations.class.getCanonicalName()); + } + + @Test + void defaultSessionConverterShouldBeJdkWhenOnClasspath() throws IllegalAccessException { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(GoodConfig.class); + this.context.refresh(); + + ReactiveMongoSessionRepository repository = this.context.getBean(ReactiveMongoSessionRepository.class); + + AbstractMongoSessionConverter converter = findMongoSessionConverter(repository); + + assertThat(converter).isOfAnyClassIn(JdkMongoSessionConverter.class); + } + + @Test + void overridingMongoSessionConverterWithBeanShouldWork() throws IllegalAccessException { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(OverrideSessionConverterConfig.class); + this.context.refresh(); + + ReactiveMongoSessionRepository repository = this.context.getBean(ReactiveMongoSessionRepository.class); + + AbstractMongoSessionConverter converter = findMongoSessionConverter(repository); + + assertThat(converter).isOfAnyClassIn(JacksonMongoSessionConverter.class); + } + + @Test + void overridingIntervalAndCollectionNameThroughAnnotationShouldWork() throws IllegalAccessException { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(OverrideMongoParametersConfig.class); + this.context.refresh(); + + ReactiveMongoSessionRepository repository = this.context.getBean(ReactiveMongoSessionRepository.class); + + Field inactiveField = ReflectionUtils.findField(ReactiveMongoSessionRepository.class, + "maxInactiveIntervalInSeconds"); + ReflectionUtils.makeAccessible(inactiveField); + Integer inactiveSeconds = (Integer) inactiveField.get(repository); + + Field collectionNameField = ReflectionUtils.findField(ReactiveMongoSessionRepository.class, "collectionName"); + ReflectionUtils.makeAccessible(collectionNameField); + String collectionName = (String) collectionNameField.get(repository); + + assertThat(inactiveSeconds).isEqualTo(123); + assertThat(collectionName).isEqualTo("test-case"); + } + + @Test + void reactiveAndBlockingMongoOperationsShouldEnsureIndexing() { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(ConfigWithReactiveAndImperativeMongoOperations.class); + this.context.refresh(); + + MongoOperations operations = this.context.getBean(MongoOperations.class); + IndexOperations indexOperations = this.context.getBean(IndexOperations.class); + + verify(operations, times(1)).indexOps((String) any()); + verify(indexOperations, times(1)).getIndexInfo(); + verify(indexOperations, times(1)).ensureIndex(any()); + } + + @Test + void overrideCollectionAndInactiveIntervalThroughConfigurationOptions() { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(CustomizedReactiveConfiguration.class); + this.context.refresh(); + + ReactiveMongoSessionRepository repository = this.context.getBean(ReactiveMongoSessionRepository.class); + + assertThat(repository.getCollectionName()).isEqualTo("custom-collection"); + assertThat(repository.getMaxInactiveIntervalInSeconds()).isEqualTo(123); + } + + @Test + void sessionRepositoryCustomizer() { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(SessionRepositoryCustomizerConfiguration.class); + this.context.refresh(); + + ReactiveMongoSessionRepository repository = this.context.getBean(ReactiveMongoSessionRepository.class); + + assertThat(repository).hasFieldOrPropertyWithValue("maxInactiveIntervalInSeconds", 10000); + } + + @Test + void customIndexResolverConfigurationWithDefaultMongoSessionConverter() { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(CustomIndexResolverConfigurationWithDefaultMongoSessionConverter.class); + this.context.refresh(); + + ReactiveMongoSessionRepository repository = this.context.getBean(ReactiveMongoSessionRepository.class); + IndexResolver indexResolver = this.context.getBean(IndexResolver.class); + + assertThat(repository).isNotNull(); + assertThat(indexResolver).isNotNull(); + assertThat(repository).extracting("mongoSessionConverter").hasFieldOrPropertyWithValue("indexResolver", + indexResolver); + } + + @Test + void customIndexResolverConfigurationWithProvidedMongoSessionConverter() { + + this.context = new AnnotationConfigApplicationContext(); + this.context.register(CustomIndexResolverConfigurationWithProvidedtMongoSessionConverter.class); + this.context.refresh(); + + ReactiveMongoSessionRepository repository = this.context.getBean(ReactiveMongoSessionRepository.class); + IndexResolver indexResolver = this.context.getBean(IndexResolver.class); + + assertThat(repository).isNotNull(); + assertThat(indexResolver).isNotNull(); + assertThat(repository).extracting("mongoSessionConverter").hasFieldOrPropertyWithValue("indexResolver", + indexResolver); + } + + /** + * Reflectively extract the {@link AbstractMongoSessionConverter} from the + * {@link ReactiveMongoSessionRepository}. This is to avoid expanding the surface area + * of the API. + */ + private AbstractMongoSessionConverter findMongoSessionConverter(ReactiveMongoSessionRepository repository) { + + Field field = ReflectionUtils.findField(ReactiveMongoSessionRepository.class, "mongoSessionConverter"); + ReflectionUtils.makeAccessible(field); + try { + return (AbstractMongoSessionConverter) field.get(repository); + } + catch (IllegalAccessException ex) { + throw new RuntimeException(ex); + } + } + + /** + * A configuration with all the right parts. + */ + @EnableMongoWebSession + static class GoodConfig { + + @Bean + ReactiveMongoOperations operations() { + return mock(ReactiveMongoOperations.class); + } + + } + + /** + * A configuration where no {@link ReactiveMongoOperations} is defined. It's BAD! + */ + @EnableMongoWebSession + static class BadConfig { + + } + + @EnableMongoWebSession + static class OverrideSessionConverterConfig { + + @Bean + ReactiveMongoOperations operations() { + return mock(ReactiveMongoOperations.class); + } + + @Bean + AbstractMongoSessionConverter mongoSessionConverter() { + return new JacksonMongoSessionConverter(); + } + + } + + @EnableMongoWebSession(maxInactiveIntervalInSeconds = 123, collectionName = "test-case") + static class OverrideMongoParametersConfig { + + @Bean + ReactiveMongoOperations operations() { + return mock(ReactiveMongoOperations.class); + } + + } + + @EnableMongoWebSession + static class ConfigWithReactiveAndImperativeMongoOperations { + + @Bean + ReactiveMongoOperations reactiveMongoOperations() { + return mock(ReactiveMongoOperations.class); + } + + @Bean + IndexOperations indexOperations() { + + IndexOperations indexOperations = mock(IndexOperations.class); + given(indexOperations.getIndexInfo()).willReturn(Collections.emptyList()); + return indexOperations; + } + + @Bean + MongoOperations mongoOperations(IndexOperations indexOperations) { + + MongoOperations mongoOperations = mock(MongoOperations.class); + given(mongoOperations.indexOps((String) any())).willReturn(indexOperations); + return mongoOperations; + } + + } + + @EnableSpringWebSession + static class CustomizedReactiveConfiguration extends ReactiveMongoWebSessionConfiguration { + + CustomizedReactiveConfiguration() { + + this.setCollectionName("custom-collection"); + this.setMaxInactiveIntervalInSeconds(123); + } + + @Bean + ReactiveMongoOperations reactiveMongoOperations() { + return mock(ReactiveMongoOperations.class); + } + + } + + @EnableMongoWebSession + static class SessionRepositoryCustomizerConfiguration { + + @Bean + ReactiveMongoOperations operations() { + return mock(ReactiveMongoOperations.class); + } + + @Bean + @Order(0) + ReactiveSessionRepositoryCustomizer sessionRepositoryCustomizerOne() { + return (sessionRepository) -> sessionRepository.setMaxInactiveIntervalInSeconds(0); + } + + @Bean + @Order(1) + ReactiveSessionRepositoryCustomizer sessionRepositoryCustomizerTwo() { + return (sessionRepository) -> sessionRepository.setMaxInactiveIntervalInSeconds(10000); + } + + } + + @EnableMongoWebSession + static class CustomIndexResolverConfigurationWithDefaultMongoSessionConverter { + + @Bean + ReactiveMongoOperations operations() { + return mock(ReactiveMongoOperations.class); + } + + @Bean + @SuppressWarnings("unchecked") + IndexResolver indexResolver() { + return mock(IndexResolver.class); + } + + } + + @EnableMongoWebSession + static class CustomIndexResolverConfigurationWithProvidedtMongoSessionConverter { + + @Bean + ReactiveMongoOperations operations() { + return mock(ReactiveMongoOperations.class); + } + + @Bean + JacksonMongoSessionConverter jacksonMongoSessionConverter() { + return new JacksonMongoSessionConverter(); + } + + @Bean + @SuppressWarnings("unchecked") + IndexResolver indexResolver() { + return mock(IndexResolver.class); + } + + } + +} diff --git a/spring-session-data-mongodb/src/test/resources/logback.xml b/spring-session-data-mongodb/src/test/resources/logback.xml new file mode 100644 index 00000000..00df754a --- /dev/null +++ b/spring-session-data-mongodb/src/test/resources/logback.xml @@ -0,0 +1,17 @@ + + + + %d{HH:mm:ss.SSS} [%8.-8thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + \ No newline at end of file diff --git a/spring-session-docs/modules/ROOT/nav.adoc b/spring-session-docs/modules/ROOT/nav.adoc index d7dc5a60..dfb51712 100644 --- a/spring-session-docs/modules/ROOT/nav.adoc +++ b/spring-session-docs/modules/ROOT/nav.adoc @@ -6,6 +6,7 @@ ***** {gh-samples-url}spring-session-sample-boot-redis-json[JSON serialization] ***** {gh-samples-url}spring-session-sample-boot-redis-simple[Simple Redis] ***** xref:guides/boot-redis.adoc[Redis with Events] +**** xref:guides/boot-mongo.adoc[MongoDB] **** xref:guides/boot-jdbc.adoc[JDBC] **** {gh-samples-url}spring-session-sample-boot-hazelcast[HttpSession with Hazelcast] *** xref:guides/boot-findbyusername.adoc[Find by Username] diff --git a/spring-session-docs/modules/ROOT/pages/guides/boot-mongo.adoc b/spring-session-docs/modules/ROOT/pages/guides/boot-mongo.adoc new file mode 100644 index 00000000..e03f539a --- /dev/null +++ b/spring-session-docs/modules/ROOT/pages/guides/boot-mongo.adoc @@ -0,0 +1,174 @@ += Spring Session - MongoDB Repositories +Jakub Kubrynski, Greg Turnquist +:stylesdir: ../ +:highlightjsdir: ../js/highlight +:docinfodir: guides + +This guide describes how to use Spring Session backed by MongoDB. + +NOTE: The completed guide can be found in the <>. + +[#index-link] +link:../index.html[Index] + +== Updating Dependencies +Before you use Spring Session MongoDB, you must ensure to update your dependencies. +We assume you are working with a working Spring Boot web application. +If you are using Maven, ensure to add the following dependencies: + +==== +[source,xml] +[subs="verbatim,attributes"] +.pom.xml +---- + + + + org.springframework.session + spring-session-data-mongodb + + +---- +==== + +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: + +==== +[source,xml] +.pom.xml +---- + + + + spring-snapshot + https://repo.spring.io/libs-snapshot + + +---- +==== +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: + +==== +[source,xml] +.pom.xml +---- + + spring-milestone + https://repo.spring.io/libs-milestone + +---- +==== +endif::[] + +[[mongo-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. + +// tag::config[] +All you have to do is to add the following Spring Configuration: + +==== +[source,java] +---- +include::{samples-dir}spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/HttpSessionConfig.java[tag=class] +---- +<1> The `@EnableMongoHttpSession` annotation creates a Spring Bean with the name of `springSessionRepositoryFilter` that implements Filter. +This filter is what replaces the default `HttpSession` with the MongoDB-backed bean. +<2> Configures the session timeout to 30 minutes. +==== + +// end::config[] + +[[boot-mongo-configuration]] +== Configuring the MongoDB Connection + +Spring Boot automatically creates a `MongoClient` that connects Spring Session to a MongoDB Server on localhost on port 27017 (default port). +In a production environment you need to ensure to update your configuration to point to your MongoDB server. +For example, you can include the following in your *application.properties* + +==== +.src/main/resources/application.properties +---- +spring.data.mongodb.host=mongo-srv +spring.data.mongodb.port=27018 +spring.data.mongodb.database=prod +---- +==== + +For more information, refer to https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-connecting-to-mongodb[Connecting to MongoDB] portion of the Spring Boot documentation. + +[[boot-servlet-configuration]] +== Servlet Container Initialization + +Our <> 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. +Last we need to ensure that our Servlet Container (i.e. Tomcat) uses our `springSessionRepositoryFilter` for every request. +Fortunately, Spring Boot takes care of both of these steps for us. + +[[mongo-sample]] +== MongoDB Sample Application + +The MongoDB Sample Application demonstrates how to use Spring Session to transparently leverage MongoDB to back a web application's `HttpSession` when using Spring Boot. + +[[mongo-running]] +=== Running the MongoDB Sample Application + +You can run the sample by obtaining the {download-url}[source code] and invoking the following command: + +==== +---- +$ ./gradlew :samples:mongo:bootRun +---- +==== + +You should now be able to access the application at http://localhost:8080/ + +[[boot-explore]] +=== 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 MongoDB rather than Tomcat's `HttpSession` implementation. + +[[mongo-how]] +=== How does it work? + +Instead of using Tomcat's `HttpSession`, we are actually persisting the values in Mongo. +Spring Session replaces the `HttpSession` with an implementation that is backed by Mongo. +When Spring Security's `SecurityContextPersistenceFilter` saves the `SecurityContext` to the `HttpSession` it is then persisted into Mongo. + +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]). + +If you like, you can easily inspect the session using mongo client. For example, on a Linux based system you can type: + +[NOTE] +==== +The sample application uses an embedded MongoDB instance that listens on a randomly allocated port. +The port used by embedded MongoDB together with exact command to connect to it is logged during application startup. +==== + + $ mongo --port ... + > use test + > db.sessions.find().pretty() + +Alternatively, you can also delete the explicit key. Enter the following into your terminal ensuring to replace `60f17293-839b-477c-bb92-07a9c3658843` with the value of your SESSION cookie: + + > db.sessions.remove({"_id":"60f17293-839b-477c-bb92-07a9c3658843"}) + +Now visit the application at http://localhost:8080/ and observe that we are no longer authenticated. diff --git a/spring-session-docs/modules/ROOT/pages/http-session.adoc b/spring-session-docs/modules/ROOT/pages/http-session.adoc index 98700711..2ce3be40 100644 --- a/spring-session-docs/modules/ROOT/pages/http-session.adoc +++ b/spring-session-docs/modules/ROOT/pages/http-session.adoc @@ -41,6 +41,68 @@ You can read the basic steps for integration in the next few sections, but we en include::guides/xml-redis.adoc[tags=config,leveloffset=+2] +[[httpsession-mongo]] +=== HttpSession with Mongo + +Using Spring Session with `HttpSession` is enabled by adding a Servlet Filter before anything that uses the `HttpSession`. + +This section describes how to use Mongo to back `HttpSession` using Java based configuration. + +NOTE: The <> provides a working sample on how to integrate Spring Session and `HttpSession` using Java configuration. +You can read the basic steps for integration below, but you are encouraged to follow along with the detailed HttpSession Guide when integrating with your own application. + +include::guides/boot-mongo.adoc[tags=config,leveloffset=+3] + +==== Session serialization mechanisms + +To be able to persist session objects in MongoDB we need to provide the serialization/deserialization mechanism. + +By default, Spring Session MongoDB will use `JdkMongoSessionConverter`. + +However, you may switch to `JacksonMongoSessionConverter` by merely adding the following code to your Boot app: + +[source,java] +---- +@Bean +JacksonMongoSessionConverter mongoSessionConverter() { + return new JacksonMongoSessionConverter(); +} +---- + +===== JacksonMongoSessionConverter + +This mechanism uses Jackson to serialize session objects to/from JSON. + +By creating the following bean: + +[source,java] +---- +@Bean +JacksonMongoSessionConverter mongoSessionConverter() { + return new JacksonMongoSessionConverter(); +} +---- + +...you are able to switch from the default (JDK-based serialization) to using Jackson. + +IMPORTANT: If you are integrating with Spring Security (by storing your sessions in MongoDB), this configuration will +register the proper whitelisted components so Spring Security works properly. + +If you would like to provide custom Jackson modules you can do it by explicitly registering modules as shown below: + +[source,java,indent=0] +---- +include::{code-dir}/src/test/java/org/springframework/session/data/mongo/integration/MongoRepositoryJacksonITest.java[tag=sample] +---- + +===== JdkMongoSessionConverter + +`JdkMongoSessionConverter` uses standard Java serialization to persist session attributes map to MongoDB in a binary form. +However, standard session elements like id, access time, etc are still written as a plain Mongo objects and can be read and queried without additional effort. +`JdkMongoSessionConverter` is used if no explicit `AbstractMongoSessionConverter` Bean has been defined. + +There is also a constructor taking `Serializer` and `Deserializer` objects, allowing you to pass custom implementations, which is especially important when you want to use non-default classloader. + [[httpsession-jdbc]] == `HttpSession` with JDBC diff --git a/spring-session-docs/modules/ROOT/pages/modules.adoc b/spring-session-docs/modules/ROOT/pages/modules.adoc index 50800bd2..24effcd8 100644 --- a/spring-session-docs/modules/ROOT/pages/modules.adoc +++ b/spring-session-docs/modules/ROOT/pages/modules.adoc @@ -4,20 +4,19 @@ In Spring Session 1.x, all of the Spring Session's `SessionRepository` implementations were available within the `spring-session` artifact. While convenient, this approach was not sustainable long-term as more features and `SessionRepository` implementations were added to the project. -Starting with Spring Session 2.0, the project has been split into Spring Session Core module and several other modules that carry `SessionRepository` implementations and functionality related to the specific data store. -Users of Spring Data should find this arrangement familiar, with Spring Session Core module taking a role equivalent to Spring Data Commons and providing core functionalities and APIs, with other modules containing data store specific implementations. -As part of this split, the Spring Session Data MongoDB and Spring Session Data GemFire modules were moved to separate repositories. -Now the situation with project's repositories/modules is as follows: +With Spring Session 2.0, several modules were split off to be separate modules as well as managed repositories. +Spring Session for MongoDB was retired, but was later reactivated as a separate module. +As of Spring Session 2.6, Spring Session for MongoDB was merged back into Spring Session. + +Now the situation with the various repositories and modules is as follows: * https://github.com/spring-projects/spring-session[`spring-session` repository] -** Hosts the Spring Session Core, Spring Session Data Redis, Spring Session JDBC, and Spring Session Hazelcast modules -* https://github.com/spring-projects/spring-session-data-mongodb[`spring-session-data-mongodb` repository] -** Hosts the Spring Session Data MongoDB module. Spring Session Data MongoDB has its own user guide, which you can find at the [https://spring.io/projects/spring-session-data-mongodb#learnSpring site]. +** Hosts the Spring Session Core, Spring Session for MongoDB, Spring Session for Redis, Spring Session JDBC, and Spring Session Hazelcast modules. * https://github.com/spring-projects/spring-session-data-geode[`spring-session-data-geode` repository] ** Hosts the Spring Session Data Geode modules. Spring Session Data Geode has its own user guide, which you can find at the [https://spring.io/projects/spring-session-data-geode#learn site]. -Finally, Spring Session now also provides a Maven BOM ("`bill of materials`") module in order to help users with version management concerns: +Finally, Spring Session also provides a Maven BOM ("`bill of materials`") module in order to help users with version management concerns: * https://github.com/spring-projects/spring-session-bom[`spring-session-bom` repository] ** Hosts the Spring Session BOM module diff --git a/spring-session-docs/modules/ROOT/pages/samples.adoc b/spring-session-docs/modules/ROOT/pages/samples.adoc index 8646d090..9fb8f670 100644 --- a/spring-session-docs/modules/ROOT/pages/samples.adoc +++ b/spring-session-docs/modules/ROOT/pages/samples.adoc @@ -43,6 +43,14 @@ To get started with Spring Session, the best place to start is our Sample Applic | Demonstrates how to use Spring Session to replace the `HttpSession` with Redis using `RedisSessionRepository`. | +| {gh-samples-url}spring-session-sample-boot-mongodb-traditional[Spring Session with MongoDB Repositories (servlet-based)] +| Demonstrates how to back Spring Session with traditional MongoDB repositories. +| link:guides/boot-mongo.html[Spring Session with MongoDB Repositories] + +| {gh-samples-url}spring-session-sample-boot-mongodb-reactive[Spring Session with MongoDB Repositories (reactive)] +| Demonstrates how to back Spring Session with reactive MongoDB repositories. +| link:guides/boot-mongo.html[Spring Session with MongoDB Repositories] + |=== .Sample Applications that use Spring Java-based configuration diff --git a/spring-session-docs/modules/ROOT/pages/upgrading.adoc b/spring-session-docs/modules/ROOT/pages/upgrading.adoc index c97a33b7..7aa7baef 100644 --- a/spring-session-docs/modules/ROOT/pages/upgrading.adoc +++ b/spring-session-docs/modules/ROOT/pages/upgrading.adoc @@ -16,19 +16,15 @@ The `spring-session-core` module holds only the common set of APIs and component This applies to several existing modules that were previously a simple dependency aggregator helper module. With new module arrangement, the following modules actually carry the implementation: -* Spring Session Data Redis +* Spring Session for MongoDB +* Spring Session for Redis * Spring Session JDBC * Spring Session Hazelcast -Also, the following modules were removed from the main project repository: +Also, the following were removed from the main project repository: -* Spring Session Data MongoDB * Spring Session Data GemFire - -Note that these two have moved to separate repositories and continue to be available under new artifact names: - -* https://github.com/spring-projects/spring-session-data-mongodb[`spring-session-data-mongodb`] -* https://github.com/spring-projects/spring-session-data-geode[`spring-session-data-geode`] +** https://github.com/spring-projects/spring-session-data-geode[`spring-session-data-geode`] == Replaced and Removed Packages, Classes, and Methods diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/spring-session-sample-boot-mongodb-reactive.gradle b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/spring-session-sample-boot-mongodb-reactive.gradle new file mode 100644 index 00000000..7e66de61 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/spring-session-sample-boot-mongodb-reactive.gradle @@ -0,0 +1,13 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + compile project(':spring-session-data-mongodb') + compile "org.springframework.boot:spring-boot-starter-webflux" + compile "org.springframework.boot:spring-boot-starter-thymeleaf" + compile "org.springframework.boot:spring-boot-starter-data-mongodb-reactive" + compile "de.flapdoodle.embed:de.flapdoodle.embed.mongo" + + testCompile "org.springframework.boot:spring-boot-starter-test" + testCompile "org.seleniumhq.selenium:htmlunit-driver" + testCompile "org.seleniumhq.selenium:selenium-support" +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SessionAttributeForm.java b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SessionAttributeForm.java new file mode 100644 index 00000000..f813b9ee --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SessionAttributeForm.java @@ -0,0 +1,74 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongodb.examples; + +import java.util.Objects; + +/** + * @author Rob Winch + * @author Greg Turnquist + * @since 5.0 + */ +public class SessionAttributeForm { + + private String attributeName; + + private String attributeValue; + + public String getAttributeName() { + return this.attributeName; + } + + public void setAttributeName(String attributeName) { + this.attributeName = attributeName; + } + + public String getAttributeValue() { + return this.attributeValue; + } + + public void setAttributeValue(String attributeValue) { + this.attributeValue = attributeValue; + } + + @Override + public boolean equals(Object o) { + + if (this == o) { + return true; + } + if (!(o instanceof SessionAttributeForm)) { + return false; + } + SessionAttributeForm that = (SessionAttributeForm) o; + return Objects.equals(this.attributeName, that.attributeName) + && Objects.equals(this.attributeValue, that.attributeValue); + } + + @Override + public int hashCode() { + return Objects.hash(this.attributeName, this.attributeValue); + } + + @Override + public String toString() { + + return "SessionAttributeForm{" + "attributeName='" + this.attributeName + '\'' + ", attributeValue='" + + this.attributeValue + '\'' + '}'; + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SessionController.java b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SessionController.java new file mode 100644 index 00000000..e00d46d2 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SessionController.java @@ -0,0 +1,49 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongodb.examples; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.server.WebSession; + +/** + * @author Rob Winch + * @author Greg Turnquist + */ +// tag::class[] +@Controller +public class SessionController { + + @PostMapping("/session") + public String setAttribute(@ModelAttribute SessionAttributeForm sessionAttributeForm, WebSession session) { + + session.getAttributes().put(sessionAttributeForm.getAttributeName(), sessionAttributeForm.getAttributeValue()); + return "redirect:/"; + } + + @GetMapping("/") + public String index(Model model, WebSession webSession) { + + model.addAttribute("webSession", webSession); + return "index"; + } + +} +// tag::end[] diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoReactiveApplication.java b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoReactiveApplication.java new file mode 100644 index 00000000..1564cfe1 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoReactiveApplication.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017 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 + * + * https://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.mongodb.examples; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.session.data.mongo.config.annotation.web.reactive.EnableMongoWebSession; + +/** + * Pure Spring-based application (using Spring Boot for dependency management), hence no + * autoconfiguration. + * + * @author Rob Winch + * @author Greg Turnquist + */ +@SpringBootApplication +@EnableMongoWebSession +public class SpringSessionMongoReactiveApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringSessionMongoReactiveApplication.class); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/resources/application.yml b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/resources/application.yml new file mode 100644 index 00000000..5ff2265b --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/resources/application.yml @@ -0,0 +1,4 @@ +logging: + level: + org.springframework.data.mongodb: DEBUG + org.springframework.session: DEBUG diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/resources/templates/index.html b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/resources/templates/index.html new file mode 100644 index 00000000..ad2bdb72 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/main/resources/templates/index.html @@ -0,0 +1,41 @@ + + + + Session Attributes + + + +
+

Description

+

This application demonstrates how to use a MongoDB instance to back your session. Notice that there is no + JSESSIONID cookie. We are also able to customize the way of identifying what the requested session id is.

+ +

Try it

+ +
+ + + + + +
+ +
+ + + + + + + + + + + + +
Attribute NameAttribute Value
+ +
+
+ + \ No newline at end of file diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/AttributeTests.java b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/AttributeTests.java new file mode 100644 index 00000000..ccc38baa --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/AttributeTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongodb.examples; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.htmlunit.HtmlUnitDriver; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.session.mongodb.examples.pages.HomePage; +import org.springframework.session.mongodb.examples.pages.HomePage.Attribute; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Eddú Meléndez + * @author Rob Winch + */ +@ExtendWith(SpringExtension.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class AttributeTests { + + @LocalServerPort + int port; + + private WebDriver driver; + + @BeforeEach + void setup() { + this.driver = new HtmlUnitDriver(); + } + + @AfterEach + void tearDown() { + this.driver.quit(); + } + + @Test + void home() { + + HomePage home = HomePage.go(this.driver, this.port); + home.assertAt(); + } + + @Test + void noAttributes() { + + HomePage home = HomePage.go(this.driver, this.port); + assertThat(home.attributes()).isEmpty(); + } + + @Test + void createAttribute() { + + HomePage home = HomePage.go(this.driver, this.port); + home = home.form().attributeName("a").attributeValue("b").submit(HomePage.class); + + List attributes = home.attributes(); + assertThat(attributes).hasSize(1); + + Attribute row = attributes.get(0); + assertThat(row.getAttributeName()).isEqualTo("a"); + assertThat(row.getAttributeValue()).isEqualTo("b"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/pages/HomePage.java b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/pages/HomePage.java new file mode 100644 index 00000000..7db5526a --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-reactive/src/test/java/org/springframework/session/mongodb/examples/pages/HomePage.java @@ -0,0 +1,148 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongodb.examples.pages; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.openqa.selenium.SearchContext; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.DefaultElementLocatorFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Eddú Meléndez + * @author Rob Winch + */ +public class HomePage { + + private WebDriver driver; + + @FindBy(css = "form") + WebElement form; + + @FindBy(css = "table tbody tr") + List trs; + + List attributes; + + public HomePage(WebDriver driver) { + + this.driver = driver; + this.attributes = new ArrayList<>(); + } + + private static void get(WebDriver driver, int port, String get) { + + String baseUrl = "http://localhost:" + port; + driver.get(baseUrl + get); + } + + public static HomePage go(WebDriver driver, int port) { + + get(driver, port, "/"); + return PageFactory.initElements(driver, HomePage.class); + } + + public void assertAt() { + assertThat(this.driver.getTitle()).isEqualTo("Session Attributes"); + } + + public List attributes() { + + List rows = this.trs.stream() // + .map(Attribute::new) // + .collect(Collectors.toList()); + + this.attributes.addAll(rows); + + return this.attributes; + } + + public Form form() { + return new Form(this.form); + } + + public class Form { + + @FindBy(name = "attributeName") + WebElement attributeName; + + @FindBy(name = "attributeValue") + WebElement attributeValue; + + @FindBy(css = "input[type=\"submit\"]") + WebElement submit; + + public Form(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + public Form attributeName(String text) { + + this.attributeName.sendKeys(text); + return this; + } + + public Form attributeValue(String text) { + + this.attributeValue.sendKeys(text); + return this; + } + + public T submit(Class page) { + + this.submit.click(); + return PageFactory.initElements(HomePage.this.driver, page); + } + + } + + public static class Attribute { + + @FindBy(xpath = ".//td[1]") + WebElement attributeName; + + @FindBy(xpath = ".//td[2]") + WebElement attributeValue; + + public Attribute(SearchContext context) { + PageFactory.initElements(new DefaultElementLocatorFactory(context), this); + } + + /** + * @return the attributeName + */ + public String getAttributeName() { + return this.attributeName.getText(); + } + + /** + * @return the attributeValue + */ + public String getAttributeValue() { + return this.attributeValue.getText(); + } + + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/spring-session-sample-boot-mongodb-traditional.gradle b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/spring-session-sample-boot-mongodb-traditional.gradle new file mode 100644 index 00000000..9323d621 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/spring-session-sample-boot-mongodb-traditional.gradle @@ -0,0 +1,17 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + compile project(':spring-session-data-mongodb') + compile "org.springframework.boot:spring-boot-starter-web" + compile "org.springframework.boot:spring-boot-starter-thymeleaf" + compile "nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect" + compile "org.thymeleaf.extras:thymeleaf-extras-springsecurity5" + compile "org.springframework.boot:spring-boot-starter-data-mongodb" + compile "org.springframework.boot:spring-boot-starter-security" + compile "de.flapdoodle.embed:de.flapdoodle.embed.mongo" + + testCompile "org.springframework.boot:spring-boot-starter-test" + testCompile "org.seleniumhq.selenium:htmlunit-driver" + testCompile "org.seleniumhq.selenium:selenium-support" + testCompile "org.springframework.security:spring-security-test" +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/EmbeddedMongoPortLogger.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/EmbeddedMongoPortLogger.java new file mode 100644 index 00000000..028291e4 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/EmbeddedMongoPortLogger.java @@ -0,0 +1,45 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongodb.examples; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +class EmbeddedMongoPortLogger implements ApplicationRunner, EnvironmentAware { + + private static final Log logger = LogFactory.getLog(EmbeddedMongoPortLogger.class); + + private Environment environment; + + @Override + public void run(ApplicationArguments args) throws Exception { + String port = this.environment.getProperty("local.mongo.port"); + logger.info("Embedded Mongo started on port " + port + ", use 'mongo --port " + port + "' command to connect"); + } + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoTraditionalBoot.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoTraditionalBoot.java new file mode 100644 index 00000000..5e1dbb20 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/SpringSessionMongoTraditionalBoot.java @@ -0,0 +1,31 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongodb.examples; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Rob Winch + */ +@SpringBootApplication +public class SpringSessionMongoTraditionalBoot { + + public static void main(String[] args) { + SpringApplication.run(SpringSessionMongoTraditionalBoot.class, args); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/HttpSessionConfig.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/HttpSessionConfig.java new file mode 100644 index 00000000..92172b13 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/HttpSessionConfig.java @@ -0,0 +1,34 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongodb.examples.config; + +import java.time.Duration; + +import org.springframework.context.annotation.Bean; +import org.springframework.session.data.mongo.JdkMongoSessionConverter; +import org.springframework.session.data.mongo.config.annotation.web.http.EnableMongoHttpSession; + +// tag::class[] +@EnableMongoHttpSession // <1> +public class HttpSessionConfig { + + @Bean + public JdkMongoSessionConverter jdkMongoSessionConverter() { + return new JdkMongoSessionConverter(Duration.ofMinutes(30)); // <2> + } + +} +// end::class[] diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/SecurityConfig.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/SecurityConfig.java new file mode 100644 index 00000000..cc300b1e --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/config/SecurityConfig.java @@ -0,0 +1,37 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongodb.examples.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.core.userdetails.User; + +/** + * @author Rob Winch + */ +@EnableWebSecurity +public class SecurityConfig extends WebSecurityConfigurerAdapter { + + @Autowired + public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { + + auth.inMemoryAuthentication().withUser( + User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build()); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/mvc/IndexController.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/mvc/IndexController.java new file mode 100644 index 00000000..57a2f2d3 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/java/org/springframework/session/mongodb/examples/mvc/IndexController.java @@ -0,0 +1,35 @@ +/* + * Copyright 2014-2016 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 + * + * https://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.mongodb.examples.mvc; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Controller for sending the user to the login view. + * + * @author Rob Winch + * + */ +@Controller +public class IndexController { + + @GetMapping("/") + public String index() { + return "index"; + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/application.properties b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/application.properties new file mode 100644 index 00000000..51ab5db5 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/application.properties @@ -0,0 +1,3 @@ +spring.thymeleaf.cache=false +spring.template.cache=false +spring.data.mongodb.port=0 diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/static/resources/img/favicon.ico b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/static/resources/img/favicon.ico new file mode 100644 index 00000000..bfb99740 Binary files /dev/null and b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/static/resources/img/favicon.ico differ diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/static/resources/img/logo.png b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/static/resources/img/logo.png new file mode 100644 index 00000000..39323088 Binary files /dev/null and b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/static/resources/img/logo.png differ diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/templates/index.html b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/templates/index.html new file mode 100644 index 00000000..4d653a1c --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/templates/index.html @@ -0,0 +1,11 @@ + + + Secured Content + + +
+

Secured Page

+

This page is secured using Spring Boot, Spring Session, and Spring Security.

+
+ + diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/templates/layout.html b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/templates/layout.html new file mode 100644 index 00000000..8f6a961c --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/main/resources/templates/layout.html @@ -0,0 +1,122 @@ + + + + Spring Session Sample + + + + + + + + + + + +
+ + +
+
+ Some Success message +
+
+ Fake content +
+
+ +
+
+ + + + diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/BootTests.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/BootTests.java new file mode 100644 index 00000000..58f37cb2 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/BootTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongodb.examples; + +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.openqa.selenium.By; +import org.openqa.selenium.Cookie; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.session.mongodb.examples.pages.HomePage; +import org.springframework.session.mongodb.examples.pages.LoginPage; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.htmlunit.webdriver.MockMvcHtmlUnitDriverBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Pool Dolorier + */ +@ExtendWith(SpringExtension.class) +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = WebEnvironment.MOCK) +public class BootTests { + + @Autowired + private MockMvc mockMvc; + + private WebDriver driver; + + @BeforeEach + void setUp() { + this.driver = MockMvcHtmlUnitDriverBuilder.mockMvcSetup(this.mockMvc).build(); + } + + @AfterEach + void tearDown() { + this.driver.quit(); + } + + @Test + void unauthenticatedUserSentToLogInPage() { + + HomePage homePage = HomePage.go(this.driver); + LoginPage loginPage = homePage.unauthenticated(); + loginPage.assertAt(); + } + + @Test + void logInViewsHomePage() { + + LoginPage loginPage = LoginPage.go(this.driver); + loginPage.assertAt(); + + HomePage homePage = loginPage.login("user", "password"); + homePage.assertAt(); + + WebElement username = homePage.getDriver().findElement(By.id("un")); + assertThat(username.getText()).isEqualTo("user"); + Set cookies = homePage.getDriver().manage().getCookies(); + assertThat(cookies).extracting("name").contains("SESSION"); + assertThat(cookies).extracting("name").doesNotContain("JSESSIONID"); + } + + @Test + void logoutSuccess() { + + LoginPage loginPage = LoginPage.go(this.driver); + HomePage homePage = loginPage.login("user", "password"); + LoginPage successLogoutPage = homePage.logout(); + + successLogoutPage.assertAt(); + } + + @Test + void loggedOutUserSentToLoginPage() { + + LoginPage loginPage = LoginPage.go(this.driver); + HomePage homePage = loginPage.login("user", "password"); + homePage.logout(); + + HomePage backHomePage = HomePage.go(this.driver); + LoginPage backLoginPage = backHomePage.unauthenticated(); + + backLoginPage.assertAt(); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/BasePage.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/BasePage.java new file mode 100644 index 00000000..29d842ad --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/BasePage.java @@ -0,0 +1,42 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongodb.examples.pages; + +import org.openqa.selenium.WebDriver; + +/** + * @author Pool Dolorier + */ +public abstract class BasePage { + + private WebDriver driver; + + public BasePage(WebDriver driver) { + this.driver = driver; + } + + public WebDriver getDriver() { + return this.driver; + } + + public static void get(WebDriver driver, String get) { + + String baseUrl = "http://localhost"; + driver.get(baseUrl + get); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/HomePage.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/HomePage.java new file mode 100644 index 00000000..2a6e1d92 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/HomePage.java @@ -0,0 +1,58 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongodb.examples.pages; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Pool Dolorier + */ +public class HomePage extends BasePage { + + @FindBy(css = "input[type='submit']") + private WebElement submit; + + public HomePage(WebDriver driver) { + super(driver); + } + + public static HomePage go(WebDriver driver) { + + get(driver, "/"); + return PageFactory.initElements(driver, HomePage.class); + } + + public LoginPage unauthenticated() { + return LoginPage.go(getDriver()); + } + + public LoginPage logout() { + + this.submit.click(); + return LoginPage.go(getDriver()); + } + + public void assertAt() { + assertThat(getDriver().getTitle()).isEqualTo("Spring Session Sample - Secured Content"); + } + +} diff --git a/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/LoginPage.java b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/LoginPage.java new file mode 100644 index 00000000..ea4091e9 --- /dev/null +++ b/spring-session-samples/spring-session-sample-boot-mongodb-traditional/src/test/java/org/springframework/session/mongodb/examples/pages/LoginPage.java @@ -0,0 +1,62 @@ +/* + * Copyright 2014-2017 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 + * + * https://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.mongodb.examples.pages; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Pool Dolorier + */ +public class LoginPage extends BasePage { + + @FindBy(name = "username") + private WebElement username; + + @FindBy(name = "password") + private WebElement password; + + @FindBy(css = "button[type='submit']") + private WebElement submit; + + public LoginPage(WebDriver driver) { + super(driver); + } + + public static LoginPage go(WebDriver driver) { + + get(driver, "/login"); + return PageFactory.initElements(driver, LoginPage.class); + } + + public void assertAt() { + assertThat(getDriver().getTitle()).isEqualTo("Please sign in"); + } + + public HomePage login(String user, String password) { + + this.username.sendKeys(user); + this.password.sendKeys(password); + this.submit.click(); + return HomePage.go(getDriver()); + } + +}