001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.web.server.session; 018 019import java.time.Clock; 020import java.time.Duration; 021import java.time.Instant; 022import java.time.ZoneId; 023import java.time.temporal.ChronoUnit; 024import java.util.Collections; 025import java.util.Iterator; 026import java.util.Map; 027import java.util.concurrent.ConcurrentHashMap; 028import java.util.concurrent.atomic.AtomicReference; 029import java.util.concurrent.locks.ReentrantLock; 030 031import reactor.core.publisher.Mono; 032import reactor.core.scheduler.Schedulers; 033 034import org.springframework.util.Assert; 035import org.springframework.util.IdGenerator; 036import org.springframework.util.JdkIdGenerator; 037import org.springframework.web.server.WebSession; 038 039/** 040 * Simple Map-based storage for {@link WebSession} instances. 041 * 042 * @author Rossen Stoyanchev 043 * @author Rob Winch 044 * @since 5.0 045 */ 046public class InMemoryWebSessionStore implements WebSessionStore { 047 048 private static final IdGenerator idGenerator = new JdkIdGenerator(); 049 050 051 private int maxSessions = 10000; 052 053 private Clock clock = Clock.system(ZoneId.of("GMT")); 054 055 private final Map<String, InMemoryWebSession> sessions = new ConcurrentHashMap<>(); 056 057 private final ExpiredSessionChecker expiredSessionChecker = new ExpiredSessionChecker(); 058 059 060 /** 061 * Set the maximum number of sessions that can be stored. Once the limit is 062 * reached, any attempt to store an additional session will result in an 063 * {@link IllegalStateException}. 064 * <p>By default set to 10000. 065 * @param maxSessions the maximum number of sessions 066 * @since 5.0.8 067 */ 068 public void setMaxSessions(int maxSessions) { 069 this.maxSessions = maxSessions; 070 } 071 072 /** 073 * Return the maximum number of sessions that can be stored. 074 * @since 5.0.8 075 */ 076 public int getMaxSessions() { 077 return this.maxSessions; 078 } 079 080 /** 081 * Configure the {@link Clock} to use to set lastAccessTime on every created 082 * session and to calculate if it is expired. 083 * <p>This may be useful to align to different timezone or to set the clock 084 * back in a test, e.g. {@code Clock.offset(clock, Duration.ofMinutes(-31))} 085 * in order to simulate session expiration. 086 * <p>By default this is {@code Clock.system(ZoneId.of("GMT"))}. 087 * @param clock the clock to use 088 */ 089 public void setClock(Clock clock) { 090 Assert.notNull(clock, "Clock is required"); 091 this.clock = clock; 092 removeExpiredSessions(); 093 } 094 095 /** 096 * Return the configured clock for session lastAccessTime calculations. 097 */ 098 public Clock getClock() { 099 return this.clock; 100 } 101 102 /** 103 * Return the map of sessions with an {@link Collections#unmodifiableMap 104 * unmodifiable} wrapper. This could be used for management purposes, to 105 * list active sessions, invalidate expired ones, etc. 106 * @since 5.0.8 107 */ 108 public Map<String, WebSession> getSessions() { 109 return Collections.unmodifiableMap(this.sessions); 110 } 111 112 113 @Override 114 public Mono<WebSession> createWebSession() { 115 116 // Opportunity to clean expired sessions 117 Instant now = this.clock.instant(); 118 this.expiredSessionChecker.checkIfNecessary(now); 119 120 return Mono.<WebSession>fromSupplier(() -> new InMemoryWebSession(now)) 121 .subscribeOn(Schedulers.boundedElastic()); 122 } 123 124 @Override 125 public Mono<WebSession> retrieveSession(String id) { 126 Instant now = this.clock.instant(); 127 this.expiredSessionChecker.checkIfNecessary(now); 128 InMemoryWebSession session = this.sessions.get(id); 129 if (session == null) { 130 return Mono.empty(); 131 } 132 else if (session.isExpired(now)) { 133 this.sessions.remove(id); 134 return Mono.empty(); 135 } 136 else { 137 session.updateLastAccessTime(now); 138 return Mono.just(session); 139 } 140 } 141 142 @Override 143 public Mono<Void> removeSession(String id) { 144 this.sessions.remove(id); 145 return Mono.empty(); 146 } 147 148 @Override 149 public Mono<WebSession> updateLastAccessTime(WebSession session) { 150 return Mono.fromSupplier(() -> { 151 Assert.isInstanceOf(InMemoryWebSession.class, session); 152 ((InMemoryWebSession) session).updateLastAccessTime(this.clock.instant()); 153 return session; 154 }); 155 } 156 157 /** 158 * Check for expired sessions and remove them. Typically such checks are 159 * kicked off lazily during calls to {@link #createWebSession() create} or 160 * {@link #retrieveSession retrieve}, no less than 60 seconds apart. 161 * This method can be called to force a check at a specific time. 162 * @since 5.0.8 163 */ 164 public void removeExpiredSessions() { 165 this.expiredSessionChecker.removeExpiredSessions(this.clock.instant()); 166 } 167 168 169 private class InMemoryWebSession implements WebSession { 170 171 private final AtomicReference<String> id = new AtomicReference<>(String.valueOf(idGenerator.generateId())); 172 173 private final Map<String, Object> attributes = new ConcurrentHashMap<>(); 174 175 private final Instant creationTime; 176 177 private volatile Instant lastAccessTime; 178 179 private volatile Duration maxIdleTime = Duration.ofMinutes(30); 180 181 private final AtomicReference<State> state = new AtomicReference<>(State.NEW); 182 183 184 public InMemoryWebSession(Instant creationTime) { 185 this.creationTime = creationTime; 186 this.lastAccessTime = this.creationTime; 187 } 188 189 @Override 190 public String getId() { 191 return this.id.get(); 192 } 193 194 @Override 195 public Map<String, Object> getAttributes() { 196 return this.attributes; 197 } 198 199 @Override 200 public Instant getCreationTime() { 201 return this.creationTime; 202 } 203 204 @Override 205 public Instant getLastAccessTime() { 206 return this.lastAccessTime; 207 } 208 209 @Override 210 public void setMaxIdleTime(Duration maxIdleTime) { 211 this.maxIdleTime = maxIdleTime; 212 } 213 214 @Override 215 public Duration getMaxIdleTime() { 216 return this.maxIdleTime; 217 } 218 219 @Override 220 public void start() { 221 this.state.compareAndSet(State.NEW, State.STARTED); 222 } 223 224 @Override 225 public boolean isStarted() { 226 return this.state.get().equals(State.STARTED) || !getAttributes().isEmpty(); 227 } 228 229 @Override 230 public Mono<Void> changeSessionId() { 231 String currentId = this.id.get(); 232 InMemoryWebSessionStore.this.sessions.remove(currentId); 233 String newId = String.valueOf(idGenerator.generateId()); 234 this.id.set(newId); 235 InMemoryWebSessionStore.this.sessions.put(this.getId(), this); 236 return Mono.empty(); 237 } 238 239 @Override 240 public Mono<Void> invalidate() { 241 this.state.set(State.EXPIRED); 242 getAttributes().clear(); 243 InMemoryWebSessionStore.this.sessions.remove(this.id.get()); 244 return Mono.empty(); 245 } 246 247 @Override 248 public Mono<Void> save() { 249 250 checkMaxSessionsLimit(); 251 252 // Implicitly started session.. 253 if (!getAttributes().isEmpty()) { 254 this.state.compareAndSet(State.NEW, State.STARTED); 255 } 256 257 if (isStarted()) { 258 // Save 259 InMemoryWebSessionStore.this.sessions.put(this.getId(), this); 260 261 // Unless it was invalidated 262 if (this.state.get().equals(State.EXPIRED)) { 263 InMemoryWebSessionStore.this.sessions.remove(this.getId()); 264 return Mono.error(new IllegalStateException("Session was invalidated")); 265 } 266 } 267 268 return Mono.empty(); 269 } 270 271 private void checkMaxSessionsLimit() { 272 if (sessions.size() >= maxSessions) { 273 expiredSessionChecker.removeExpiredSessions(clock.instant()); 274 if (sessions.size() >= maxSessions) { 275 throw new IllegalStateException("Max sessions limit reached: " + sessions.size()); 276 } 277 } 278 } 279 280 @Override 281 public boolean isExpired() { 282 return isExpired(clock.instant()); 283 } 284 285 private boolean isExpired(Instant now) { 286 if (this.state.get().equals(State.EXPIRED)) { 287 return true; 288 } 289 if (checkExpired(now)) { 290 this.state.set(State.EXPIRED); 291 return true; 292 } 293 return false; 294 } 295 296 private boolean checkExpired(Instant currentTime) { 297 return isStarted() && !this.maxIdleTime.isNegative() && 298 currentTime.minus(this.maxIdleTime).isAfter(this.lastAccessTime); 299 } 300 301 private void updateLastAccessTime(Instant currentTime) { 302 this.lastAccessTime = currentTime; 303 } 304 } 305 306 307 private class ExpiredSessionChecker { 308 309 /** Max time between expiration checks. */ 310 private static final int CHECK_PERIOD = 60 * 1000; 311 312 313 private final ReentrantLock lock = new ReentrantLock(); 314 315 private Instant checkTime = clock.instant().plus(CHECK_PERIOD, ChronoUnit.MILLIS); 316 317 318 public void checkIfNecessary(Instant now) { 319 if (this.checkTime.isBefore(now)) { 320 removeExpiredSessions(now); 321 } 322 } 323 324 public void removeExpiredSessions(Instant now) { 325 if (sessions.isEmpty()) { 326 return; 327 } 328 if (this.lock.tryLock()) { 329 try { 330 Iterator<InMemoryWebSession> iterator = sessions.values().iterator(); 331 while (iterator.hasNext()) { 332 InMemoryWebSession session = iterator.next(); 333 if (session.isExpired(now)) { 334 iterator.remove(); 335 session.invalidate(); 336 } 337 } 338 } 339 finally { 340 this.checkTime = now.plus(CHECK_PERIOD, ChronoUnit.MILLIS); 341 this.lock.unlock(); 342 } 343 } 344 } 345 } 346 347 348 private enum State { NEW, STARTED, EXPIRED } 349 350}