Skip to content
This repository was archived by the owner on Apr 4, 2025. It is now read-only.

Commit af80b65

Browse files
committed
Track change of MongoSession's id to properly delete.
When a session is made invalid and changed to a new one, the old one must be deleted from MongoDB at the next save(). Resolves #116.
1 parent d6206cc commit af80b65

File tree

5 files changed

+284
-3
lines changed

5 files changed

+284
-3
lines changed

pom.xml

+39
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,24 @@
525525
<scope>test</scope>
526526
</dependency>
527527

528+
<dependency>
529+
<groupId>org.springframework</groupId>
530+
<artifactId>spring-webflux</artifactId>
531+
<scope>test</scope>
532+
</dependency>
533+
534+
<dependency>
535+
<groupId>org.springframework.security</groupId>
536+
<artifactId>spring-security-config</artifactId>
537+
<scope>test</scope>
538+
</dependency>
539+
540+
<dependency>
541+
<groupId>org.springframework.security</groupId>
542+
<artifactId>spring-security-web</artifactId>
543+
<scope>test</scope>
544+
</dependency>
545+
528546
<dependency>
529547
<groupId>de.flapdoodle.embed</groupId>
530548
<artifactId>de.flapdoodle.embed.mongo</artifactId>
@@ -575,6 +593,27 @@
575593
<scope>test</scope>
576594
</dependency>
577595

596+
<dependency>
597+
<groupId>org.hamcrest</groupId>
598+
<artifactId>hamcrest</artifactId>
599+
<version>2.1</version>
600+
<scope>test</scope>
601+
</dependency>
602+
603+
<dependency>
604+
<groupId>ch.qos.logback</groupId>
605+
<artifactId>logback-classic</artifactId>
606+
<version>1.2.3</version>
607+
<scope>test</scope>
608+
</dependency>
609+
610+
<dependency>
611+
<groupId>ch.qos.logback</groupId>
612+
<artifactId>logback-core</artifactId>
613+
<version>1.2.3</version>
614+
<scope>test</scope>
615+
</dependency>
616+
578617
<dependency>
579618
<groupId>org.mockito</groupId>
580619
<artifactId>mockito-core</artifactId>

src/main/java/org/springframework/session/data/mongo/MongoSession.java

+23-2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public class MongoSession implements Session {
4343
private static final char DOT_COVER_CHAR = '';
4444

4545
private String id;
46+
private String originalSessionId;
4647
private long createdMillis = System.currentTimeMillis();
4748
private long accessedMillis;
4849
private long intervalSeconds;
@@ -60,6 +61,7 @@ public MongoSession(long maxInactiveIntervalInSeconds) {
6061
public MongoSession(String id, long maxInactiveIntervalInSeconds) {
6162

6263
this.id = id;
64+
this.originalSessionId = id;
6365
this.intervalSeconds = maxInactiveIntervalInSeconds;
6466
setLastAccessedTime(Instant.ofEpochMilli(this.createdMillis));
6567
}
@@ -72,6 +74,7 @@ static String uncoverDot(String attributeName) {
7274
return attributeName.replace(DOT_COVER_CHAR, '.');
7375
}
7476

77+
@Override
7578
public String changeSessionId() {
7679

7780
String changedId = UUID.randomUUID().toString();
@@ -85,10 +88,12 @@ public <T> T getAttribute(String attributeName) {
8588
return (T) this.attrs.get(coverDot(attributeName));
8689
}
8790

91+
@Override
8892
public Set<String> getAttributeNames() {
8993
return this.attrs.keySet().stream().map(MongoSession::uncoverDot).collect(Collectors.toSet());
9094
}
9195

96+
@Override
9297
public void setAttribute(String attributeName, Object attributeValue) {
9398

9499
if (attributeValue == null) {
@@ -98,10 +103,12 @@ public void setAttribute(String attributeName, Object attributeValue) {
98103
}
99104
}
100105

106+
@Override
101107
public void removeAttribute(String attributeName) {
102108
this.attrs.remove(coverDot(attributeName));
103109
}
104110

111+
@Override
105112
public Instant getCreationTime() {
106113
return Instant.ofEpochMilli(this.createdMillis);
107114
}
@@ -110,24 +117,29 @@ public void setCreationTime(long created) {
110117
this.createdMillis = created;
111118
}
112119

120+
@Override
113121
public Instant getLastAccessedTime() {
114122
return Instant.ofEpochMilli(this.accessedMillis);
115123
}
116124

125+
@Override
117126
public void setLastAccessedTime(Instant lastAccessedTime) {
118127

119128
this.accessedMillis = lastAccessedTime.toEpochMilli();
120129
this.expireAt = Date.from(lastAccessedTime.plus(Duration.ofSeconds(this.intervalSeconds)));
121130
}
122131

132+
@Override
123133
public Duration getMaxInactiveInterval() {
124134
return Duration.ofSeconds(this.intervalSeconds);
125135
}
126136

137+
@Override
127138
public void setMaxInactiveInterval(Duration interval) {
128139
this.intervalSeconds = interval.getSeconds();
129140
}
130141

142+
@Override
131143
public boolean isExpired() {
132144
return this.intervalSeconds >= 0 && new Date().after(this.expireAt);
133145
}
@@ -140,14 +152,15 @@ public boolean equals(Object o) {
140152
if (o == null || getClass() != o.getClass())
141153
return false;
142154
MongoSession that = (MongoSession) o;
143-
return Objects.equals(id, that.id);
155+
return Objects.equals(this.id, that.id);
144156
}
145157

146158
@Override
147159
public int hashCode() {
148-
return Objects.hash(id);
160+
return Objects.hash(this.id);
149161
}
150162

163+
@Override
151164
public String getId() {
152165
return this.id;
153166
}
@@ -159,4 +172,12 @@ public Date getExpireAt() {
159172
public void setExpireAt(final Date expireAt) {
160173
this.expireAt = expireAt;
161174
}
175+
176+
boolean hasChangedSessionId() {
177+
return !getId().equals(this.originalSessionId);
178+
}
179+
180+
String getOriginalSessionId() {
181+
return this.originalSessionId;
182+
}
162183
}

src/main/java/org/springframework/session/data/mongo/ReactiveMongoSessionRepository.java

+9-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
*/
1616
package org.springframework.session.data.mongo;
1717

18+
import static org.springframework.data.mongodb.core.query.Criteria.*;
19+
import static org.springframework.data.mongodb.core.query.Query.*;
1820
import static org.springframework.session.data.mongo.MongoSessionUtils.*;
1921

2022
import java.time.Duration;
@@ -94,7 +96,13 @@ public Mono<Void> save(MongoSession session) {
9496

9597
DBObject dbObject = convertToDBObject(this.mongoSessionConverter, session);
9698
if (dbObject != null) {
97-
return this.mongoOperations.save(dbObject, this.collectionName).then();
99+
if (session.hasChangedSessionId()) {
100+
return this.mongoOperations.findAndRemove(query(where("_id").is(session.getOriginalSessionId())), MongoSession.class, this.collectionName)
101+
.then(this.mongoOperations.save(dbObject, this.collectionName))
102+
.then();
103+
} else {
104+
return this.mongoOperations.save(dbObject, this.collectionName).then();
105+
}
98106
} else {
99107
return Mono.empty();
100108
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/*
2+
* Copyright 2019 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.session.data.mongo.integration;
17+
18+
import static org.assertj.core.api.AssertionsForClassTypes.*;
19+
20+
import java.io.IOException;
21+
import java.net.URI;
22+
23+
import de.flapdoodle.embed.mongo.MongodExecutable;
24+
import org.junit.jupiter.api.BeforeEach;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.api.extension.ExtendWith;
27+
import reactor.test.StepVerifier;
28+
import org.springframework.beans.factory.annotation.Autowired;
29+
import org.springframework.context.ApplicationContext;
30+
import org.springframework.context.annotation.Bean;
31+
import org.springframework.context.annotation.Configuration;
32+
import org.springframework.data.mongodb.core.ReactiveMongoOperations;
33+
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
34+
import org.springframework.http.HttpHeaders;
35+
import org.springframework.http.MediaType;
36+
import org.springframework.http.ResponseEntity;
37+
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
38+
import org.springframework.security.config.web.server.ServerHttpSecurity;
39+
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
40+
import org.springframework.security.core.userdetails.User;
41+
import org.springframework.security.web.server.SecurityWebFilterChain;
42+
import org.springframework.session.data.mongo.config.annotation.web.reactive.EnableMongoWebSession;
43+
import org.springframework.test.context.ContextConfiguration;
44+
import org.springframework.test.context.junit.jupiter.SpringExtension;
45+
import org.springframework.test.web.reactive.server.FluxExchangeResult;
46+
import org.springframework.test.web.reactive.server.WebTestClient;
47+
import org.springframework.util.SocketUtils;
48+
import org.springframework.web.bind.annotation.GetMapping;
49+
import org.springframework.web.bind.annotation.RestController;
50+
import org.springframework.web.reactive.config.EnableWebFlux;
51+
import org.springframework.web.reactive.function.BodyInserters;
52+
53+
import com.mongodb.reactivestreams.client.MongoClient;
54+
import com.mongodb.reactivestreams.client.MongoClients;
55+
56+
/**
57+
* @author Greg Turnquist
58+
*/
59+
@ExtendWith(SpringExtension.class)
60+
@ContextConfiguration
61+
public class MongoDbLogoutVerificationTest {
62+
63+
@Autowired ApplicationContext ctx;
64+
65+
WebTestClient client;
66+
67+
@BeforeEach
68+
void setUp() {
69+
this.client = WebTestClient.bindToApplicationContext(this.ctx).build();
70+
}
71+
72+
@Test
73+
void logoutShouldDeleteOldSessionIdFromMongoDB() {
74+
75+
// 1. `curl -i -v -X POST --data "username=admin&password=password" localhost:8080/login` - Save SESSION cookie and
76+
// use it it nex step as {cookie-value-1}
77+
78+
FluxExchangeResult<String> loginResult = this.client.post().uri("/login")
79+
.contentType(MediaType.APPLICATION_FORM_URLENCODED) //
80+
.body(BodyInserters //
81+
.fromFormData("username", "admin") //
82+
.with("password", "password")) //
83+
.exchange() //
84+
.returnResult(String.class);
85+
86+
assertThat(loginResult.getResponseHeaders().getLocation()).isEqualTo(URI.create("/"));
87+
88+
String originalSessionId = loginResult.getResponseCookies().getFirst("SESSION").getValue();
89+
90+
// 2. `curl -i -L -v -X GET --cookie "SESSION=48eb6ab2-2c08-43b7-a303-46099bfef231" localhost:8080/hello` - response
91+
// status will be 200, body will be "HelloWorld"
92+
93+
this.client.get().uri("/hello") //
94+
.cookie("SESSION", originalSessionId) //
95+
.exchange() //
96+
.expectStatus().isOk() //
97+
.returnResult(String.class).getResponseBody() //
98+
.as(StepVerifier::create) //
99+
.expectNext("HelloWorld") //
100+
.verifyComplete();
101+
102+
// 3. `curl -i -L -v -X POST --cookie "SESSION=48eb6ab2-2c08-43b7-a303-46099bfef231" localhost:8080/logout` - Save
103+
// SESSION cookie and use it it nex step as {cookie-value-2}
104+
105+
String newSessionId = this.client.post().uri("/logout") //
106+
.cookie("SESSION", originalSessionId) //
107+
.exchange() //
108+
.expectStatus().isFound() //
109+
.returnResult(String.class)
110+
.getResponseCookies().getFirst("SESSION").getValue();
111+
112+
assertThat(newSessionId).isNotEqualTo(originalSessionId);
113+
114+
// 4. `curl -i -L -v -X GET --cookie "SESSION=3b20200c-cf5e-4529-b3af-3c37ed365f5a" localhost:8080/hello` - response
115+
// status will be 302, body will be empty
116+
117+
this.client.get().uri("/hello") //
118+
.cookie("SESSION", newSessionId) //
119+
.exchange() //
120+
.expectStatus().isFound() //
121+
.expectHeader().value(HttpHeaders.LOCATION, value -> assertThat(value).isEqualTo("/login"));
122+
123+
// 5. `curl -i -L -v -X GET --cookie "SESSION=48eb6ab2-2c08-43b7-a303-46099bfef231" localhost:8080/hello` - response
124+
// status will be 200, body will be "HelloWorld", but it should be the same as step 4
125+
126+
this.client.get().uri("/hello") //
127+
.cookie("SESSION", originalSessionId) //
128+
.exchange() //
129+
.expectStatus().isFound() //
130+
.expectHeader().value(HttpHeaders.LOCATION, value -> assertThat(value).isEqualTo("/login"));
131+
}
132+
133+
@RestController
134+
static class TestController {
135+
136+
@GetMapping("/hello")
137+
public ResponseEntity<String> hello() {
138+
return ResponseEntity.ok("HelloWorld");
139+
}
140+
141+
}
142+
143+
@EnableWebFluxSecurity
144+
static class SecurityConfig {
145+
146+
@Bean
147+
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
148+
149+
return http //
150+
.logout()//
151+
/**/.and() //
152+
.formLogin() //
153+
/**/.and() //
154+
.csrf().disable() //
155+
.authorizeExchange() //
156+
.anyExchange().authenticated() //
157+
/**/.and() //
158+
.build();
159+
}
160+
161+
@Bean
162+
public MapReactiveUserDetailsService userDetailsService() {
163+
164+
return new MapReactiveUserDetailsService(User.withDefaultPasswordEncoder() //
165+
.username("admin") //
166+
.password("password") //
167+
.roles("USER,ADMIN") //
168+
.build());
169+
}
170+
}
171+
172+
@Configuration
173+
@EnableWebFlux
174+
@EnableMongoWebSession
175+
static class Config {
176+
177+
private int embeddedMongoPort = SocketUtils.findAvailableTcpPort();
178+
179+
@Bean(initMethod = "start", destroyMethod = "stop")
180+
public MongodExecutable embeddedMongoServer() throws IOException {
181+
return MongoITestUtils.embeddedMongoServer(this.embeddedMongoPort);
182+
}
183+
184+
@Bean
185+
public ReactiveMongoOperations mongoOperations(MongodExecutable embeddedMongoServer) {
186+
187+
MongoClient mongo = MongoClients.create("mongodb://localhost:" + this.embeddedMongoPort);
188+
return new ReactiveMongoTemplate(mongo, "test");
189+
}
190+
191+
@Bean
192+
TestController controller() {
193+
return new TestController();
194+
}
195+
}
196+
}

0 commit comments

Comments
 (0)