diff --git a/spring-web-reactive/src/main/java/org/springframework/http/HttpCookie.java b/spring-web-reactive/src/main/java/org/springframework/http/HttpCookie.java index 5021b62614..81a762f352 100644 --- a/spring-web-reactive/src/main/java/org/springframework/http/HttpCookie.java +++ b/spring-web-reactive/src/main/java/org/springframework/http/HttpCookie.java @@ -15,6 +15,8 @@ */ package org.springframework.http; +import java.time.Duration; + import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -34,7 +36,7 @@ public final class HttpCookie { private final String value; - private final int maxAge; + private final Duration maxAge; private final String domain; @@ -46,17 +48,17 @@ public final class HttpCookie { private HttpCookie(String name, String value) { - this(name, value, -1, null, null, false, false); + this(name, value, Duration.ofSeconds(-1), null, null, false, false); } - private HttpCookie(String name, String value, int maxAge, String domain, String path, + private HttpCookie(String name, String value, Duration maxAge, String domain, String path, boolean secure, boolean httpOnly) { Assert.hasLength(name, "'name' is required and must not be empty."); - Assert.hasLength(value, "'value' is required and must not be empty."); + Assert.notNull(maxAge); this.name = name; - this.value = value; - this.maxAge = (maxAge > -1 ? maxAge : -1); + this.value = (value != null ? value : ""); + this.maxAge = maxAge; this.domain = domain; this.path = path; this.secure = secure; @@ -85,7 +87,7 @@ public final class HttpCookie { * A negative value means no "Max-Age" attribute in which case the cookie * is removed when the browser is closed. */ - public int getMaxAge() { + public Duration getMaxAge() { return this.maxAge; } @@ -162,7 +164,7 @@ public final class HttpCookie { return new HttpCookieBuilder() { - private int maxAge = -1; + private Duration maxAge = Duration.ofSeconds(-1); private String domain; @@ -174,7 +176,7 @@ public final class HttpCookie { @Override - public HttpCookieBuilder maxAge(int maxAge) { + public HttpCookieBuilder maxAge(Duration maxAge) { this.maxAge = maxAge; return this; } @@ -217,14 +219,14 @@ public final class HttpCookie { public interface HttpCookieBuilder { /** - * Set the cookie "Max-Age" attribute in seconds. + * Set the cookie "Max-Age" attribute. * *

A positive value indicates when the cookie should expire relative * to the current time. A value of 0 means the cookie should expire * immediately. A negative value results in no "Max-Age" attribute in * which case the cookie is removed when the browser is closed. */ - HttpCookieBuilder maxAge(int maxAge); + HttpCookieBuilder maxAge(Duration maxAge); /** * Set the cookie "Path" attribute. diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpResponse.java index 9dbcf0a2bb..704ddf4db6 100644 --- a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpResponse.java +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/AbstractServerHttpResponse.java @@ -66,25 +66,23 @@ public abstract class AbstractServerHttpResponse implements ServerHttpResponse { } private Mono applyBeforeCommit() { - return Stream.defer(() -> { - Mono mono = Mono.empty(); - if (this.state.compareAndSet(State.NEW, State.COMMITTING)) { - for (Supplier> action : this.beforeCommitActions) { - mono = mono.after(() -> action.get()); - } - mono = mono.otherwise(ex -> { - // Ignore errors from beforeCommit actions - return Mono.empty(); - }); - mono = mono.after(() -> { - this.state.set(State.COMITTED); - writeHeaders(); - writeCookies(); - return Mono.empty(); - }); + Mono mono = Mono.empty(); + if (this.state.compareAndSet(State.NEW, State.COMMITTING)) { + for (Supplier> action : this.beforeCommitActions) { + mono = mono.after(() -> action.get()); } - return mono; - }).after(); + mono = mono.otherwise(ex -> { + // Ignore errors from beforeCommit actions + return Mono.empty(); + }); + mono = mono.after(() -> { + this.state.set(State.COMITTED); + writeHeaders(); + writeCookies(); + return Mono.empty(); + }); + } + return mono; } /** diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java index 8b0f3bcd19..011b3d599a 100644 --- a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ReactorServerHttpResponse.java @@ -15,6 +15,8 @@ */ package org.springframework.http.server.reactive; +import java.time.Duration; + import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -73,55 +75,59 @@ public class ReactorServerHttpResponse extends AbstractServerHttpResponse { protected void writeCookies() { for (String name : getHeaders().getCookies().keySet()) { for (HttpCookie httpCookie : getHeaders().getCookies().get(name)) { - Cookie cookie = new ReactorCookie(name, httpCookie); - this.channel.addResponseCookie(name, cookie); + Cookie reactorCookie = new ReactorCookie(httpCookie); + this.channel.addResponseCookie(name, reactorCookie); } } } + + /** + * At present Reactor does not provide a {@link Cookie} implementation. + */ private final static class ReactorCookie extends Cookie { - final HttpCookie httpCookie; - final String name; + private final HttpCookie httpCookie; - public ReactorCookie(String name, HttpCookie httpCookie) { - this.name = name; + + public ReactorCookie(HttpCookie httpCookie) { this.httpCookie = httpCookie; } @Override public String name() { - return name; + return this.httpCookie.getName(); } @Override public String value() { - return httpCookie.getValue(); + return this.httpCookie.getValue(); } @Override public boolean httpOnly() { - return httpCookie.isHttpOnly(); + return this.httpCookie.isHttpOnly(); } @Override public long maxAge() { - return httpCookie.getMaxAge() > -1 ? httpCookie.getMaxAge() : -1; + Duration maxAge = this.httpCookie.getMaxAge(); + return (!maxAge.isNegative() ? maxAge.getSeconds() : -1); } @Override public String domain() { - return httpCookie.getDomain(); + return this.httpCookie.getDomain(); } @Override public String path() { - return httpCookie.getPath(); + return this.httpCookie.getPath(); } @Override public boolean secure() { - return httpCookie.isSecure(); + return this.httpCookie.isSecure(); } } } diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpResponse.java index 417e6b0a50..e1ad9ad646 100644 --- a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpResponse.java +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/RxNettyServerHttpResponse.java @@ -82,8 +82,8 @@ public class RxNettyServerHttpResponse extends AbstractServerHttpResponse { for (String name : getHeaders().getCookies().keySet()) { for (HttpCookie httpCookie : getHeaders().getCookies().get(name)) { Cookie cookie = new DefaultCookie(name, httpCookie.getValue()); - if (httpCookie.getMaxAge() > -1) { - cookie.setMaxAge(httpCookie.getMaxAge()); + if (!httpCookie.getMaxAge().isNegative()) { + cookie.setMaxAge(httpCookie.getMaxAge().getSeconds()); } cookie.setDomain(httpCookie.getDomain()); cookie.setPath(httpCookie.getPath()); diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java index 5bc34daa71..cbfb6e7342 100644 --- a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpRequest.java @@ -113,14 +113,17 @@ public class ServletServerHttpRequest extends AbstractServerHttpRequest { @Override protected void initCookies(Map> map) { - for (Cookie cookie : this.request.getCookies()) { - String name = cookie.getName(); - List list = map.get(name); - if (list == null) { - list = new ArrayList<>(); - map.put(name, list); + Cookie[] cookies = this.request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + String name = cookie.getName(); + List list = map.get(name); + if (list == null) { + list = new ArrayList<>(); + map.put(name, list); + } + list.add(HttpCookie.clientCookie(name, cookie.getValue())); } - list.add(HttpCookie.clientCookie(name, cookie.getValue())); } } diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java index 887baa567a..eeb50d96c8 100644 --- a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/ServletServerHttpResponse.java @@ -91,8 +91,8 @@ public class ServletServerHttpResponse extends AbstractServerHttpResponse { for (String name : getHeaders().getCookies().keySet()) { for (HttpCookie httpCookie : getHeaders().getCookies().get(name)) { Cookie cookie = new Cookie(name, httpCookie.getValue()); - if (httpCookie.getMaxAge() > -1) { - cookie.setMaxAge(httpCookie.getMaxAge()); + if (!httpCookie.getMaxAge().isNegative()) { + cookie.setMaxAge((int) httpCookie.getMaxAge().getSeconds()); } if (httpCookie.getDomain() != null) { cookie.setDomain(httpCookie.getDomain()); diff --git a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java index c4d8929b34..dcaa12d233 100644 --- a/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java +++ b/spring-web-reactive/src/main/java/org/springframework/http/server/reactive/UndertowServerHttpResponse.java @@ -83,8 +83,8 @@ public class UndertowServerHttpResponse extends AbstractServerHttpResponse { for (String name : getHeaders().getCookies().keySet()) { for (HttpCookie httpCookie : getHeaders().getCookies().get(name)) { Cookie cookie = new CookieImpl(name, httpCookie.getValue()); - if (httpCookie.getMaxAge() > -1) { - cookie.setMaxAge(httpCookie.getMaxAge()); + if (!httpCookie.getMaxAge().isNegative()) { + cookie.setMaxAge((int) httpCookie.getMaxAge().getSeconds()); } cookie.setDomain(httpCookie.getDomain()); cookie.setPath(httpCookie.getPath()); diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/DefaultWebServerExchange.java b/spring-web-reactive/src/main/java/org/springframework/web/server/DefaultWebServerExchange.java index fe5cb4f5db..49703cddb6 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/server/DefaultWebServerExchange.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/DefaultWebServerExchange.java @@ -18,9 +18,14 @@ package org.springframework.web.server; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import reactor.core.publisher.FluxProcessor; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Processors; + import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.Assert; +import org.springframework.web.server.session.WebSessionManager; /** * Default implementation of {@link WebServerExchange}. @@ -33,14 +38,26 @@ public class DefaultWebServerExchange implements WebServerExchange { private final ServerHttpResponse response; + private final WebSessionManager sessionManager; + + private final Map attributes = new ConcurrentHashMap<>(); + private final Object createSessionLock = new Object(); + + private Mono sessionMono; + + + + public DefaultWebServerExchange(ServerHttpRequest request, ServerHttpResponse response, + WebSessionManager sessionManager) { - public DefaultWebServerExchange(ServerHttpRequest request, ServerHttpResponse response) { Assert.notNull(request, "'request' is required."); Assert.notNull(response, "'response' is required."); + Assert.notNull(response, "'sessionManager' is required."); this.request = request; this.response = response; + this.sessionManager = sessionManager; } @@ -59,4 +76,17 @@ public class DefaultWebServerExchange implements WebServerExchange { return this.attributes; } + @Override + public Mono getSession() { + if (this.sessionMono == null) { + synchronized (this.createSessionLock) { + if (this.sessionMono == null) { + FluxProcessor replay = Processors.replay(1); + this.sessionMono = this.sessionManager.getSession(this).subscribeWith(replay).next(); + } + } + } + return this.sessionMono; + } + } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebServerExchange.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebServerExchange.java index 87fbf7ac3c..53d1006e0a 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/server/WebServerExchange.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebServerExchange.java @@ -17,6 +17,8 @@ package org.springframework.web.server; import java.util.Map; +import reactor.core.publisher.Mono; + import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; @@ -30,18 +32,23 @@ import org.springframework.http.server.reactive.ServerHttpResponse; public interface WebServerExchange { /** - * @return the current HTTP request + * Return the current HTTP request. */ ServerHttpRequest getRequest(); /** - * @return the current HTTP response + * Return the current HTTP response. */ ServerHttpResponse getResponse(); /** - * @return mutable map of request attributes for the current exchange + * Return a mutable map of request attributes for the current exchange. */ Map getAttributes(); + /** + * + */ + Mono getSession(); + } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebSession.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebSession.java new file mode 100644 index 0000000000..ca6554cbcf --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebSession.java @@ -0,0 +1,109 @@ +/* + * Copyright 2002-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; + +import reactor.core.publisher.Mono; + +/** + * Main contract for using a server-side session that provides access to session + * attributes across HTTP requests. + * + *

The creation of a {@code WebSession} instance does not automatically start + * a session thus causing the session id to be sent to the client (typically via + * a cookie). A session starts implicitly when session attributes are added. + * A session may also be created explicitly via {@link #start()}. + * + * @author Rossen Stoyanchev + */ +public interface WebSession { + + /** + * Return a unique session identifier. + */ + String getId(); + + /** + * Return a map that holds session attributes. + */ + Map getAttributes(); + + /** + * Force the creation of a session causing the session id to be sent when + * {@link #save()} is called. + */ + void start(); + + /** + * Whether a session with the client has been started explicitly via + * {@link #start()} or implicitly by adding session attributes. + * If "false" then the session id is not sent to the client and the + * {@link #save()} method is essentially a no-op. + */ + boolean isStarted(); + + /** + * Save the session persisting attributes (e.g. if stored remotely) and also + * sending the session id to the client if the session is new. + *

Note that a session must be started explicitly via {@link #start()} or + * implicitly by adding attributes or otherwise this method has no effect. + * @return {@code Mono} to indicate completion with success or error + *

Typically this method should be automatically invoked just before the + * response is committed so applications don't have to by default. + */ + Mono save(); + + /** + * Return {@code true} if the session expired after {@link #getMaxIdleTime() + * maxIdleTime} elapsed. + *

Typically expiration checks should be automatically made when a session + * is accessed, a new {@code WebSession} instance created if necessary, at + * the start of request processing so that applications don't have to worry + * about expired session by default. + */ + boolean isExpired(); + + /** + * Return the time when the session was created. + */ + Instant getCreationTime(); + + /** + * Return the last time of session access as a result of user activity such + * as an HTTP request. Together with {@link #getMaxIdleTime() + * maxIdleTimeInSeconds} this helps to determine when a session is + * {@link #isExpired() expired}. + */ + Instant getLastAccessTime(); + + /** + * Configure the max amount of time that may elapse after the + * {@link #getLastAccessTime() lastAccessTime} before a session is considered + * expired. A negative value indicates the session should not expire. + */ + void setMaxIdleTime(Duration maxIdleTime); + + /** + * Return the maximum time after the {@link #getLastAccessTime() + * lastAccessTime} before a session expires. A negative time indicates the + * session doesn't expire. + */ + Duration getMaxIdleTime(); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebToHttpHandlerAdapter.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebToHttpHandlerAdapter.java index 6b6b203327..c85ba349b4 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/server/WebToHttpHandlerAdapter.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebToHttpHandlerAdapter.java @@ -23,6 +23,9 @@ import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.util.Assert; +import org.springframework.web.server.session.DefaultWebSessionManager; +import org.springframework.web.server.session.WebSessionManager; /** * Adapt {@link WebHandler} to {@link HttpHandler} also creating the @@ -35,11 +38,31 @@ public class WebToHttpHandlerAdapter extends WebHandlerDecorator implements Http private static Log logger = LogFactory.getLog(WebToHttpHandlerAdapter.class); + private WebSessionManager sessionManager = new DefaultWebSessionManager(); + + public WebToHttpHandlerAdapter(WebHandler delegate) { super(delegate); } + /** + * + * @param sessionManager + */ + public void setSessionManager(WebSessionManager sessionManager) { + Assert.notNull(sessionManager, "'sessionManager' must not be null."); + this.sessionManager = sessionManager; + } + + /** + * Return the configured {@link WebSessionManager}. + */ + public WebSessionManager getSessionManager() { + return this.sessionManager; + } + + @Override public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { WebServerExchange exchange = createWebServerExchange(request, response); @@ -55,7 +78,7 @@ public class WebToHttpHandlerAdapter extends WebHandlerDecorator implements Http } protected WebServerExchange createWebServerExchange(ServerHttpRequest request, ServerHttpResponse response) { - return new DefaultWebServerExchange(request, response); + return new DefaultWebServerExchange(request, response, this.sessionManager); } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/WebToHttpHandlerBuilder.java b/spring-web-reactive/src/main/java/org/springframework/web/server/WebToHttpHandlerBuilder.java index 62a133a35e..55dc3960f5 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/server/WebToHttpHandlerBuilder.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/WebToHttpHandlerBuilder.java @@ -21,6 +21,7 @@ import java.util.List; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; +import org.springframework.web.server.session.WebSessionManager; /** * Assist with building an @@ -43,6 +44,8 @@ public class WebToHttpHandlerBuilder { private final List exceptionHandlers = new ArrayList<>(); + private WebSessionManager sessionManager; + private WebToHttpHandlerBuilder(WebHandler targetHandler) { Assert.notNull(targetHandler, "'targetHandler' must not be null"); @@ -68,17 +71,26 @@ public class WebToHttpHandlerBuilder { return this; } + public WebToHttpHandlerBuilder sessionManager(WebSessionManager sessionManager) { + this.sessionManager = sessionManager; + return this; + } + public WebToHttpHandlerAdapter build() { - WebHandler webHandler = this.targetHandler; + WebHandler handler = this.targetHandler; if (!this.exceptionHandlers.isEmpty()) { WebExceptionHandler[] array = new WebExceptionHandler[this.exceptionHandlers.size()]; - webHandler = new ExceptionHandlingWebHandler(webHandler, this.exceptionHandlers.toArray(array)); + handler = new ExceptionHandlingWebHandler(handler, this.exceptionHandlers.toArray(array)); } if (!this.filters.isEmpty()) { WebFilter[] array = new WebFilter[this.filters.size()]; - webHandler = new FilteringWebHandler(webHandler, this.filters.toArray(array)); + handler = new FilteringWebHandler(handler, this.filters.toArray(array)); } - return new WebToHttpHandlerAdapter(webHandler); + WebToHttpHandlerAdapter adapter = new WebToHttpHandlerAdapter(handler); + if (this.sessionManager != null) { + adapter.setSessionManager(this.sessionManager); + } + return adapter; } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/ConfigurableWebSession.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/ConfigurableWebSession.java new file mode 100644 index 0000000000..6a91b3f219 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/ConfigurableWebSession.java @@ -0,0 +1,45 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import java.time.Instant; +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.WebSession; + +/** + * Extend {@link WebSession} with management operations meant for internal use + * for example by implementations of {@link WebSessionManager}. + * + * @author Rossen Stoyanchev + */ +public interface ConfigurableWebSession extends WebSession { + + /** + * Update the last access time for user-related session activity. + * @param time the time of access + */ + void setLastAccessTime(Instant time); + + /** + * Set the operation to invoke when {@link WebSession#save()} is invoked. + * @param saveOperation the save operation + */ + void setSaveOperation(Supplier> saveOperation); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/CookieWebSessionIdResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/CookieWebSessionIdResolver.java new file mode 100644 index 0000000000..2584487c2b --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/CookieWebSessionIdResolver.java @@ -0,0 +1,93 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.springframework.http.HttpCookie; +import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.server.WebServerExchange; + +/** + * Cookie-based {@link WebSessionIdResolver}. + * + * @author Rossen Stoyanchev + */ +public class CookieWebSessionIdResolver implements WebSessionIdResolver { + + private String cookieName = "SESSION"; + + private Duration cookieMaxAge = Duration.ofSeconds(-1); + + + /** + * Set the name of the cookie to use for the session id. + *

By default set to "SESSION". + * @param cookieName the cookie name + */ + public void setCookieName(String cookieName) { + Assert.hasText(cookieName, "'cookieName' must not be empty."); + this.cookieName = cookieName; + } + + /** + * Return the configured cookie name. + */ + public String getCookieName() { + return this.cookieName; + } + + /** + * Set the value for the "Max-Age" attribute of the cookie that holds the + * session id. For the range of values see {@link HttpCookie#getMaxAge()}. + *

By default set to -1. + * @param maxAge the maxAge duration value + */ + public void setCookieMaxAge(Duration maxAge) { + this.cookieMaxAge = maxAge; + } + + /** + * Return the configured "Max-Age" attribute value for the session cookie. + */ + public Duration getCookieMaxAge() { + return this.cookieMaxAge; + } + + + @Override + public Optional resolveSessionId(WebServerExchange exchange) { + HttpHeaders headers = exchange.getRequest().getHeaders(); + List cookies = headers.getCookies().get(getCookieName()); + return (CollectionUtils.isEmpty(cookies) ? + Optional.empty() : Optional.of(cookies.get(0).getValue())); + } + + @Override + public void setSessionId(WebServerExchange exchange, String id) { + Duration maxAge = (StringUtils.hasText(id) ? getCookieMaxAge() : Duration.ofSeconds(0)); + HttpCookie cookie = HttpCookie.serverCookie(getCookieName(), id).maxAge(maxAge).build(); + HttpHeaders headers = exchange.getResponse().getHeaders(); + headers.getCookies().put(getCookieName(), Collections.singletonList(cookie)); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSession.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSession.java new file mode 100644 index 0000000000..0301563994 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSession.java @@ -0,0 +1,169 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import java.io.Serializable; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; + +import org.springframework.util.Assert; + +/** + * @author Rossen Stoyanchev + */ +public class DefaultWebSession implements ConfigurableWebSession, Serializable { + + private final String id; + + private final Map attributes; + + private final Clock clock; + + private final Instant creationTime; + + private volatile Instant lastAccessTime; + + private volatile Duration maxIdleTime; + + private AtomicReference state = new AtomicReference<>(); + + private volatile transient Supplier> saveOperation; + + + /** + * Constructor to create a new session. + * @param id the session id + * @param clock for access to current time + */ + public DefaultWebSession(String id, Clock clock) { + Assert.notNull(id, "'id' is required."); + Assert.notNull(clock, "'clock' is required."); + this.id = id; + this.clock = clock; + this.attributes = new ConcurrentHashMap<>(); + this.creationTime = Instant.now(clock); + this.lastAccessTime = this.creationTime; + this.maxIdleTime = Duration.ofMinutes(30); + this.state.set(State.NEW); + } + + /** + * Constructor to load existing session. + * @param id the session id + * @param attributes the attributes of the session + * @param clock for access to current time + * @param creationTime the creation time + * @param lastAccessTime the last access time + * @param maxIdleTime the configured maximum session idle time + */ + public DefaultWebSession(String id, Map attributes, Clock clock, + Instant creationTime, Instant lastAccessTime, Duration maxIdleTime) { + + Assert.notNull(id, "'id' is required."); + Assert.notNull(clock, "'clock' is required."); + this.id = id; + this.attributes = new ConcurrentHashMap<>(attributes); + this.clock = clock; + this.creationTime = creationTime; + this.lastAccessTime = lastAccessTime; + this.maxIdleTime = maxIdleTime; + this.state.set(State.STARTED); + } + + + @Override + public String getId() { + return this.id; + } + + @Override + public Map getAttributes() { + return this.attributes; + } + + @Override + public Instant getCreationTime() { + return this.creationTime; + } + + @Override + public void setLastAccessTime(Instant lastAccessTime) { + this.lastAccessTime = lastAccessTime; + } + + @Override + public Instant getLastAccessTime() { + return this.lastAccessTime; + } + + /** + *

By default this is set to 30 minutes. + * @param maxIdleTime the max idle time + */ + @Override + public void setMaxIdleTime(Duration maxIdleTime) { + this.maxIdleTime = maxIdleTime; + } + + @Override + public Duration getMaxIdleTime() { + return this.maxIdleTime; + } + + @Override + public void setSaveOperation(Supplier> saveOperation) { + Assert.notNull(saveOperation, "'saveOperation' is required."); + this.saveOperation = saveOperation; + } + + protected Supplier> getSaveOperation() { + return this.saveOperation; + } + + + @Override + public void start() { + this.state.compareAndSet(State.NEW, State.STARTED); + } + + @Override + public boolean isStarted() { + State value = this.state.get(); + return (State.STARTED.equals(value) || (State.NEW.equals(value) && !getAttributes().isEmpty())); + } + + @Override + public Mono save() { + return this.saveOperation.get(); + } + + @Override + public boolean isExpired() { + return (isStarted() && !this.maxIdleTime.isNegative() && + Instant.now(this.clock).minus(this.maxIdleTime).isAfter(this.lastAccessTime)); + } + + + private enum State { NEW, STARTED } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSessionManager.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSessionManager.java new file mode 100644 index 0000000000..f374449215 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/DefaultWebSessionManager.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import reactor.core.publisher.Mono; + +import org.springframework.util.Assert; +import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.WebSession; + + +/** + * Default implementation of {@link WebSessionManager} with a cookie-based web + * session id resolution strategy and simple in-memory session persistence. + * + * @author Rossen Stoyanchev + */ +public class DefaultWebSessionManager implements WebSessionManager { + + private WebSessionIdResolver sessionIdResolver = new CookieWebSessionIdResolver(); + + private WebSessionStore sessionStore = new InMemoryWebSessionStore(); + + private Clock clock = Clock.systemDefaultZone(); + + + /** + * Configure the session id resolution strategy to use. + *

By default {@link CookieWebSessionIdResolver} is used. + * @param sessionIdResolver the resolver + */ + public void setSessionIdResolver(WebSessionIdResolver sessionIdResolver) { + Assert.notNull(sessionIdResolver, "'sessionIdResolver' is required."); + this.sessionIdResolver = sessionIdResolver; + } + + /** + * Return the configured {@link WebSessionIdResolver}. + */ + public WebSessionIdResolver getSessionIdResolver() { + return this.sessionIdResolver; + } + + /** + * Configure the session persistence strategy to use. + *

By default {@link InMemoryWebSessionStore} is used. + * @param sessionStore the persistence strategy + */ + public void setSessionStore(WebSessionStore sessionStore) { + Assert.notNull(sessionStore, "'sessionStore' is required."); + this.sessionStore = sessionStore; + } + + /** + * Return the configured {@link WebSessionStore}. + */ + public WebSessionStore getSessionStore() { + return this.sessionStore; + } + + /** + * Configure the {@link Clock} for access to current time. During tests you + * may use {code Clock.offset(clock, Duration.ofMinutes(-31))} to set the + * clock back for example to test changes after sessions expire. + *

By default {@link Clock#systemDefaultZone()} is used. + * @param clock the clock to use + */ + public void setClock(Clock clock) { + Assert.notNull(clock, "'clock' is required."); + this.clock = clock; + } + + /** + * Return the configured clock for access to current time. + */ + public Clock getClock() { + return this.clock; + } + + + @Override + public Mono getSession(WebServerExchange exchange) { + return Mono.fromCallable(() -> getSessionIdResolver().resolveSessionId(exchange)) + .where(Optional::isPresent) + .map(Optional::get) + .then(this.sessionStore::retrieveSession) + .then(session -> validateSession(exchange, session)) + .otherwiseIfEmpty(createSession(exchange)) + .map(session -> extendSession(exchange, session)); + } + + protected Mono validateSession(WebServerExchange exchange, WebSession session) { + if (session.isExpired()) { + this.sessionIdResolver.setSessionId(exchange, ""); + return this.sessionStore.removeSession(session.getId()).after(Mono::empty); + } + else { + return Mono.just(session); + } + } + + protected Mono createSession(WebServerExchange exchange) { + String sessionId = UUID.randomUUID().toString(); + WebSession session = new DefaultWebSession(sessionId, getClock()); + return Mono.just(session); + } + + protected WebSession extendSession(WebServerExchange exchange, WebSession session) { + if (session instanceof ConfigurableWebSession) { + ConfigurableWebSession managed = (ConfigurableWebSession) session; + managed.setSaveOperation(() -> saveSession(exchange, session)); + managed.setLastAccessTime(Instant.now(getClock())); + } + exchange.getResponse().beforeCommit(session::save); + return session; + } + + protected Mono saveSession(WebServerExchange exchange, WebSession session) { + + Assert.isTrue(!session.isExpired(), "Sessions are checked for expiration and have their " + + "access time updated when first accessed during request processing. " + + "However this session is expired meaning that maxIdleTime elapsed " + + "since then and before the call to session.save()."); + + if (!session.isStarted()) { + return Mono.empty(); + } + + // Force explicit start + session.start(); + + Optional requestedId = getSessionIdResolver().resolveSessionId(exchange); + if (!requestedId.isPresent() || !session.getId().equals(requestedId.get())) { + this.sessionIdResolver.setSessionId(exchange, session.getId()); + } + return this.sessionStore.storeSession(session); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java new file mode 100644 index 0000000000..7e1bb388a3 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java @@ -0,0 +1,52 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.WebSession; + +/** + * Simple Map-based storage for {@link WebSession} instances. + * + * @author Rossen Stoyanchev + */ +public class InMemoryWebSessionStore implements WebSessionStore { + + private final Map sessions = new ConcurrentHashMap<>(); + + + @Override + public Mono storeSession(WebSession session) { + this.sessions.put(session.getId(), session); + return Mono.empty(); + } + + @Override + public Mono retrieveSession(String id) { + return (this.sessions.containsKey(id) ? Mono.just(this.sessions.get(id)) : Mono.empty()); + } + + @Override + public Mono removeSession(String id) { + this.sessions.remove(id); + return Mono.empty(); + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionIdResolver.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionIdResolver.java new file mode 100644 index 0000000000..61c28260f2 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionIdResolver.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import java.util.Optional; + +import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.WebSession; + + +/** + * Contract for session id resolution strategies. Allows for session id + * resolution through the request and for sending the session id to the + * client through the response. + * + * @author Rossen Stoyanchev + * @see CookieWebSessionIdResolver + */ +public interface WebSessionIdResolver { + + /** + * Resolve the session id associated with the request. + * @param exchange the current exchange + * @return the session id if present + */ + Optional resolveSessionId(WebServerExchange exchange); + + /** + * Send the given session id to the client or if the session id is "null" + * instruct the client to end the current session. + * @param exchange the current exchange + * @param sessionId the session id + */ + void setSessionId(WebServerExchange exchange, String sessionId); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionManager.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionManager.java new file mode 100644 index 0000000000..63909b072e --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionManager.java @@ -0,0 +1,48 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.WebSession; + +/** + * Main contract abstracting support for access to {@link WebSession} instances + * associated with HTTP requests as well as the subsequent management such as + * persistence and others. + * + *

The {@link DefaultWebSessionManager} implementation in turn delegates to + * {@link WebSessionIdResolver} and {@link WebSessionStore} which abstract + * underlying concerns related to the management of web sessions. + * + * @author Rossen Stoyanchev + * @see WebSessionIdResolver + * @see WebSessionStore + */ +public interface WebSessionManager { + + /** + * Return the {@link WebSession} for the given exchange. Always guaranteed + * to return an instance either matching to the session id requested by the + * client, or with a new session id either because the client did not + * specify one or because the underlying session had expired. + * @param exchange the current exchange + * @return {@code Mono} for async access to the session + */ + Mono getSession(WebServerExchange exchange); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionStore.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionStore.java new file mode 100644 index 0000000000..998e298e69 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/WebSessionStore.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.WebSession; + +/** + * Strategy for {@link WebSession} persistence. + * + * @author Rossen Stoyanchev + * @since 4.3 + */ +public interface WebSessionStore { + + /** + * Store the given session. + * @param session the session to store + * @return {@code Mono} for completion notification + */ + Mono storeSession(WebSession session); + + /** + * Load the session for the given session id. + * @param sessionId the session to load + * @return {@code Mono} for async access to the loaded session + */ + Mono retrieveSession(String sessionId); + + /** + * Remove the session with the given id. + * @param sessionId the session to remove + * @return {@code Mono} for completion notification + */ + Mono removeSession(String sessionId); + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/server/session/package-info.java b/spring-web-reactive/src/main/java/org/springframework/web/server/session/package-info.java new file mode 100644 index 0000000000..57b15561b0 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/server/session/package-info.java @@ -0,0 +1,4 @@ +/** + * Support for a user session. + */ +package org.springframework.web.server.session; diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java index 033148a846..c609536cde 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/DispatcherHandlerErrorTests.java @@ -57,9 +57,11 @@ import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; import org.springframework.web.server.WebHandler; import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.session.WebSessionManager; import static org.hamcrest.CoreMatchers.startsWith; import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; /** * Test the effect of exceptions at different stages of request processing by @@ -91,9 +93,11 @@ public class DispatcherHandlerErrorTests { this.dispatcherHandler = new DispatcherHandler(); this.dispatcherHandler.setApplicationContext(appContext); + WebSessionManager sessionManager = mock(WebSessionManager.class); + this.request = new MockServerHttpRequest(HttpMethod.GET, new URI("/")); this.response = new MockServerHttpResponse(); - this.exchange = new DefaultWebServerExchange(this.request, this.response); + this.exchange = new DefaultWebServerExchange(this.request, this.response, sessionManager); } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/ResponseStatusExceptionHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/ResponseStatusExceptionHandlerTests.java index 8a992da11b..6c48305a88 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/ResponseStatusExceptionHandlerTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/ResponseStatusExceptionHandlerTests.java @@ -31,10 +31,12 @@ import org.springframework.http.server.reactive.MockServerHttpResponse; import org.springframework.web.ResponseStatusException; import org.springframework.web.server.DefaultWebServerExchange; import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.session.WebSessionManager; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; /** * @author Rossen Stoyanchev @@ -52,8 +54,9 @@ public class ResponseStatusExceptionHandlerTests { public void setUp() throws Exception { this.handler = new ResponseStatusExceptionHandler(); MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + WebSessionManager sessionManager = mock(WebSessionManager.class); this.response = new MockServerHttpResponse(); - this.exchange = new DefaultWebServerExchange(request, this.response); + this.exchange = new DefaultWebServerExchange(request, this.response, sessionManager); } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/InvocableHandlerMethodTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/InvocableHandlerMethodTests.java index c0385cde11..903de4bb19 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/InvocableHandlerMethodTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/InvocableHandlerMethodTests.java @@ -37,6 +37,7 @@ import org.springframework.web.reactive.HandlerResult; import org.springframework.web.reactive.method.annotation.RequestParamArgumentResolver; import org.springframework.web.server.DefaultWebServerExchange; import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.session.WebSessionManager; import static org.junit.Assert.assertEquals; import static org.mockito.Matchers.any; @@ -56,8 +57,9 @@ public class InvocableHandlerMethodTests { @Before public void setUp() throws Exception { + WebSessionManager sessionManager = mock(WebSessionManager.class); this.request = mock(ServerHttpRequest.class); - this.exchange = new DefaultWebServerExchange(request, mock(ServerHttpResponse.class)); + this.exchange = new DefaultWebServerExchange(request, mock(ServerHttpResponse.class), sessionManager); } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/RequestMappingHandlerMappingTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/RequestMappingHandlerMappingTests.java index 8ce0f57628..c14153c954 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/RequestMappingHandlerMappingTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/method/annotation/RequestMappingHandlerMappingTests.java @@ -36,10 +36,12 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.method.HandlerMethod; import org.springframework.web.server.DefaultWebServerExchange; import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.session.WebSessionManager; import static java.util.stream.Collectors.toList; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; /** * @author Sebastien Deleuze @@ -62,7 +64,9 @@ public class RequestMappingHandlerMappingTests { @Test public void path() throws Exception { ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("boo")); - WebServerExchange exchange = new DefaultWebServerExchange(request, new MockServerHttpResponse()); + MockServerHttpResponse response = new MockServerHttpResponse(); + WebSessionManager sessionManager = mock(WebSessionManager.class); + WebServerExchange exchange = new DefaultWebServerExchange(request, response, sessionManager); Publisher handlerPublisher = this.mapping.getHandler(exchange); HandlerMethod handlerMethod = toHandlerMethod(handlerPublisher); assertEquals(TestController.class.getMethod("boo"), handlerMethod.getMethod()); @@ -71,13 +75,15 @@ public class RequestMappingHandlerMappingTests { @Test public void method() throws Exception { ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.POST, new URI("foo")); - WebServerExchange exchange = new DefaultWebServerExchange(request, new MockServerHttpResponse()); + MockServerHttpResponse response = new MockServerHttpResponse(); + WebSessionManager sessionManager = mock(WebSessionManager.class); + WebServerExchange exchange = new DefaultWebServerExchange(request, response, sessionManager); Publisher handlerPublisher = this.mapping.getHandler(exchange); HandlerMethod handlerMethod = toHandlerMethod(handlerPublisher); assertEquals(TestController.class.getMethod("postFoo"), handlerMethod.getMethod()); request = new MockServerHttpRequest(HttpMethod.GET, new URI("foo")); - exchange = new DefaultWebServerExchange(request, new MockServerHttpResponse()); + exchange = new DefaultWebServerExchange(request, new MockServerHttpResponse(), sessionManager); handlerPublisher = this.mapping.getHandler(exchange); handlerMethod = toHandlerMethod(handlerPublisher); assertEquals(TestController.class.getMethod("getFoo"), handlerMethod.getMethod()); diff --git a/spring-web-reactive/src/test/java/org/springframework/web/server/ExceptionHandlingHttpHandlerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/server/ExceptionHandlingHttpHandlerTests.java index 0360c17ace..e640ceb486 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/server/ExceptionHandlingHttpHandlerTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/server/ExceptionHandlingHttpHandlerTests.java @@ -26,8 +26,10 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.MockServerHttpRequest; import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.server.session.WebSessionManager; import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; /** * @author Rossen Stoyanchev @@ -45,9 +47,10 @@ public class ExceptionHandlingHttpHandlerTests { @Before public void setUp() throws Exception { URI uri = new URI("http://localhost:8080"); + WebSessionManager sessionManager = mock(WebSessionManager.class); MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, uri); this.response = new MockServerHttpResponse(); - this.exchange = new DefaultWebServerExchange(request, this.response); + this.exchange = new DefaultWebServerExchange(request, this.response, sessionManager); this.targetHandler = new StubWebHandler(new IllegalStateException("boo")); } diff --git a/spring-web-reactive/src/test/java/org/springframework/web/server/session/DefaultWebSessionManagerTests.java b/spring-web-reactive/src/test/java/org/springframework/web/server/session/DefaultWebSessionManagerTests.java new file mode 100644 index 0000000000..37c676fc6e --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/server/session/DefaultWebSessionManagerTests.java @@ -0,0 +1,154 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.web.server.DefaultWebServerExchange; +import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.WebSession; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * @author Rossen Stoyanchev + */ +public class DefaultWebSessionManagerTests { + + private DefaultWebSessionManager manager; + + private TestWebSessionIdResolver idResolver; + + private DefaultWebServerExchange exchange; + + + @Before + public void setUp() throws Exception { + this.idResolver = new TestWebSessionIdResolver(); + this.manager = new DefaultWebSessionManager(); + this.manager.setSessionIdResolver(this.idResolver); + + MockServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("/path")); + MockServerHttpResponse response = new MockServerHttpResponse(); + this.exchange = new DefaultWebServerExchange(request, response, this.manager); + } + + + @Test + public void getSessionPassive() throws Exception { + this.idResolver.setIdToResolve(Optional.empty()); + WebSession session = this.manager.getSession(this.exchange).get(); + + assertNotNull(session); + assertFalse(session.isStarted()); + assertFalse(session.isExpired()); + + session.save(); + + assertFalse(this.idResolver.getId().isPresent()); + assertNull(this.manager.getSessionStore().retrieveSession(session.getId()).get()); + } + + @Test + public void getSessionForceCreate() throws Exception { + this.idResolver.setIdToResolve(Optional.empty()); + WebSession session = this.manager.getSession(this.exchange).get(); + session.start(); + session.save(); + + String id = session.getId(); + assertTrue(this.idResolver.getId().isPresent()); + assertEquals(id, this.idResolver.getId().get()); + assertSame(session, this.manager.getSessionStore().retrieveSession(id).get()); + } + + @Test + public void getSessionAddAttribute() throws Exception { + this.idResolver.setIdToResolve(Optional.empty()); + WebSession session = this.manager.getSession(this.exchange).get(); + session.getAttributes().put("foo", "bar"); + session.save(); + + assertTrue(this.idResolver.getId().isPresent()); + } + + @Test + public void getSessionExisting() throws Exception { + DefaultWebSession existing = new DefaultWebSession("1", Clock.systemDefaultZone()); + this.manager.getSessionStore().storeSession(existing); + + this.idResolver.setIdToResolve(Optional.of("1")); + WebSession actual = this.manager.getSession(this.exchange).get(); + assertSame(existing, actual); + } + + @Test + public void getSessionExistingExpired() throws Exception { + Clock clock = Clock.systemDefaultZone(); + DefaultWebSession existing = new DefaultWebSession("1", clock); + existing.start(); + existing.setLastAccessTime(Instant.now(clock).minus(Duration.ofMinutes(31))); + this.manager.getSessionStore().storeSession(existing); + + this.idResolver.setIdToResolve(Optional.of("1")); + WebSession actual = this.manager.getSession(this.exchange).get(); + assertNotSame(existing, actual); + } + + + private static class TestWebSessionIdResolver implements WebSessionIdResolver { + + private Optional idToResolve = Optional.empty(); + + private Optional id = Optional.empty(); + + + public void setIdToResolve(Optional idToResolve) { + this.idToResolve = idToResolve; + } + + public Optional getId() { + return this.id; + } + + @Override + public Optional resolveSessionId(WebServerExchange exchange) { + return this.idToResolve; + } + + @Override + public void setSessionId(WebServerExchange exchange, Optional sessionId) { + this.id = sessionId; + } + } + +} diff --git a/spring-web-reactive/src/test/java/org/springframework/web/server/session/WebSessionIntegrationTests.java b/spring-web-reactive/src/test/java/org/springframework/web/server/session/WebSessionIntegrationTests.java new file mode 100644 index 0000000000..c4e80f0d14 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/server/session/WebSessionIntegrationTests.java @@ -0,0 +1,167 @@ +/* + * Copyright 2002-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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.web.server.session; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.AbstractHttpHandlerIntegrationTests; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.WebServerExchange; +import org.springframework.web.server.WebToHttpHandlerBuilder; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + + +/** + * @author Rossen Stoyanchev + */ +public class WebSessionIntegrationTests extends AbstractHttpHandlerIntegrationTests { + + private RestTemplate restTemplate; + + private DefaultWebSessionManager sessionManager; + + private TestWebHandler handler; + + + @Override + public void setup() throws Exception { + super.setup(); + this.restTemplate = new RestTemplate(); + } + + protected URI createUri(String pathAndQuery) throws URISyntaxException { + boolean prefix = !StringUtils.hasText(pathAndQuery) || !pathAndQuery.startsWith("/"); + pathAndQuery = (prefix ? "/" + pathAndQuery : pathAndQuery); + return new URI("http://localhost:" + port + pathAndQuery); + } + + @Override + protected HttpHandler createHttpHandler() { + this.sessionManager = new DefaultWebSessionManager(); + this.handler = new TestWebHandler(); + return WebToHttpHandlerBuilder.webHandler(this.handler).sessionManager(this.sessionManager).build(); + } + + @Test + public void createSession() throws Exception { + RequestEntity request = RequestEntity.get(createUri("/")).build(); + ResponseEntity response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + String id = extractSessionId(response.getHeaders()); + assertNotNull(id); + assertEquals(1, this.handler.getCount()); + + request = RequestEntity.get(createUri("/")).header("Cookie", "SESSION=" + id).build(); + response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNull(response.getHeaders().get("Set-Cookie")); + assertEquals(2, this.handler.getCount()); + } + + @Test + public void expiredSession() throws Exception { + RequestEntity request = RequestEntity.get(createUri("/")).build(); + ResponseEntity response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + String id = extractSessionId(response.getHeaders()); + assertNotNull(id); + assertEquals(1, this.handler.getCount()); + + // Set clock back 31 minutes + Clock clock = this.sessionManager.getClock(); + this.sessionManager.setClock(Clock.offset(clock, Duration.ofMinutes(-31))); + + // Access again to update lastAccessTime + request = RequestEntity.get(createUri("/")).header("Cookie", "SESSION=" + id).build(); + response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNull(response.getHeaders().get("Set-Cookie")); + assertEquals(2, this.handler.getCount()); + + // Now it should be expired + request = RequestEntity.get(createUri("/")).header("Cookie", "SESSION=" + id).build(); + response = this.restTemplate.exchange(request, Void.class); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + id = extractSessionId(response.getHeaders()); + assertNotNull("Expected new session id", id); + assertEquals("Expected new session attribute", 1, this.handler.getCount()); + } + + + // No client side HttpCookie support yet + + private String extractSessionId(HttpHeaders headers) { + List headerValues = headers.get("Set-Cookie"); + assertNotNull(headerValues); + assertEquals(1, headerValues.size()); + + List data = new ArrayList<>(); + for (String s : headerValues.get(0).split(";")){ + if (s.startsWith("SESSION=")) { + return s.substring("SESSION=".length()); + } + } + return null; + } + + private static class TestWebHandler implements WebHandler { + + private AtomicInteger currentValue = new AtomicInteger(); + + + public int getCount() { + return this.currentValue.get(); + } + + @Override + public Mono handle(WebServerExchange exchange) { + return exchange.getSession().map(session -> { + Map map = session.getAttributes(); + int value = (map.get("counter") != null ? (int) map.get("counter") : 0); + value++; + map.put("counter", value); + this.currentValue.set(value); + return session; + }).after(); + } + } + +}