diff --git a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java index 635453ab834d..74c10ef2dd28 100644 --- a/spring-web/src/main/java/org/springframework/http/HttpHeaders.java +++ b/spring-web/src/main/java/org/springframework/http/HttpHeaders.java @@ -1549,13 +1549,9 @@ private ZonedDateTime getFirstZonedDateTime(String headerName, boolean rejectInv headerValue = headerValue.substring(0, parametersIndex); } - for (DateTimeFormatter dateFormatter : DATE_PARSERS) { - try { - return ZonedDateTime.parse(headerValue, dateFormatter); - } - catch (DateTimeParseException ex) { - // ignore - } + ZonedDateTime zonedDateTime = getZonedDateTime(headerValue); + if (zonedDateTime != null) { + return zonedDateTime; } } @@ -1566,6 +1562,26 @@ private ZonedDateTime getFirstZonedDateTime(String headerName, boolean rejectInv return null; } + /** + * Parses the date in headers with Date formats specified in the HTTP RFC to use for parsing. + * {@link HttpHeaders#DATE_PARSERS} + * @param date the date header value as string + * @return the parsed date header value + */ + // used in ClientHttpResponse + @Nullable + public static ZonedDateTime getZonedDateTime(String date) { + for (DateTimeFormatter dateFormatter : DATE_PARSERS) { + try { + return ZonedDateTime.parse(date, dateFormatter); + } + catch (DateTimeParseException ex) { + // ignore + } + } + return null; + } + /** * Return all values of a given header name, even if this header is set * multiple times. diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java index f4c022e8be1d..ec38b581c823 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ClientHttpResponse.java @@ -16,9 +16,15 @@ package org.springframework.http.client.reactive; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; + +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.ReactiveHttpInputMessage; import org.springframework.http.ResponseCookie; +import org.springframework.lang.Nullable; import org.springframework.util.MultiValueMap; import org.springframework.util.ObjectUtils; @@ -51,4 +57,24 @@ default String getId() { */ MultiValueMap getCookies(); + static long mergeMaxAgeAndExpires(@Nullable String maxAgeAttribute, @Nullable String expiresAttribute) { + if (maxAgeAttribute != null) { + return Long.parseLong(maxAgeAttribute); + } + else if (expiresAttribute != null) { + ZonedDateTime expiresDate = HttpHeaders.getZonedDateTime(expiresAttribute); + + // Verify that the input date is in the future + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + if (expiresDate == null || expiresDate.isBefore(now)) { + return -1; + } + else { + // Calculate the difference in seconds + return ChronoUnit.SECONDS.between(now, expiresDate); + } + } + return -1; + } + } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpResponse.java index ce8b9482d60b..d1e7685be908 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/HttpComponentsClientHttpResponse.java @@ -70,8 +70,7 @@ private static MultiValueMap adaptCookies(HttpClientCont } private static long getMaxAgeSeconds(Cookie cookie) { - String maxAgeAttribute = cookie.getAttribute(Cookie.MAX_AGE_ATTR); - return (maxAgeAttribute != null ? Long.parseLong(maxAgeAttribute) : -1); + return ClientHttpResponse.mergeMaxAgeAndExpires(cookie.getAttribute(Cookie.MAX_AGE_ATTR), cookie.getAttribute(Cookie.EXPIRES_ATTR)); } diff --git a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpResponse.java b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpResponse.java index 2af3933d0e12..3e65b6581ac2 100644 --- a/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpResponse.java +++ b/spring-web/src/main/java/org/springframework/http/client/reactive/ReactorNetty2ClientHttpResponse.java @@ -128,7 +128,7 @@ public MultiValueMap getCookies() { ResponseCookie.fromClientResponse(cookie.name().toString(), cookie.value().toString()) .domain(toString(cookie.domain())) .path(toString(cookie.path())) - .maxAge(toLong(cookie.maxAge())) + .maxAge(getMaxAgeSeconds(cookie)) .secure(cookie.isSecure()) .httpOnly(cookie.isHttpOnly()) .sameSite(getSameSite(cookie)) @@ -136,15 +136,18 @@ public MultiValueMap getCookies() { return CollectionUtils.unmodifiableMultiValueMap(result); } + private static long getMaxAgeSeconds(HttpSetCookie cookie) { + String maxAge = (cookie.maxAge() == null) ? null : cookie.maxAge().toString(); + String expires = toString(cookie.expires()); + + return ClientHttpResponse.mergeMaxAgeAndExpires(maxAge, expires); + } + @Nullable private static String toString(@Nullable CharSequence value) { return (value != null ? value.toString() : null); } - private static long toLong(@Nullable Long value) { - return (value != null ? value : -1); - } - @Nullable private static String getSameSite(HttpSetCookie cookie) { if (cookie instanceof DefaultHttpSetCookie defaultCookie && defaultCookie.sameSite() != null) { diff --git a/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java b/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java index d3ee539b9c42..906938d43bcf 100644 --- a/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java +++ b/spring-web/src/test/java/org/springframework/http/client/reactive/ClientHttpConnectorTests.java @@ -23,6 +23,9 @@ import java.lang.annotation.Target; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -48,9 +51,11 @@ import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpCookie; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ReactiveHttpOutputMessage; +import org.springframework.http.ResponseCookie; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; @@ -65,6 +70,9 @@ class ClientHttpConnectorTests { private static final Set METHODS_WITH_BODY = Set.of(HttpMethod.PUT, HttpMethod.POST, HttpMethod.PATCH); + public static final String MAX_AGE_AND_EXPIRES = "Max-Age-And-Expires"; + public static final String MAX_AGE_ONLY = "Max-Age-Only"; + public static final String EXPIRES_ONLY = "Expires-Only"; private final MockWebServer server = new MockWebServer(); @@ -172,6 +180,70 @@ void cancelResponseBody(ClientHttpConnector connector) { .verify(); } + @ParameterizedConnectorTest + void testExpiresMaxAgeAttributes(ClientHttpConnector connector) { + // maxAge is set to 12 days from system time. + long maxAge = 1036800L; + + // expires date is set to 10 days from system time. + long expires = 864000L; + ZonedDateTime currentDateTime = ZonedDateTime.now(java.time.ZoneOffset.UTC); + ZonedDateTime futureDateTime = currentDateTime.plusSeconds(expires); + + // processing time may affect the calculation of ZonedDateTime.now during merge of expires and max age + // therefore we check range with buffer of 2 seconds + long maxAgeLowerLimit = maxAge - 2; + long maxAgeUpperLimit = maxAge + 2; + long expiresLowerLimit = expires - 2; + long expiresUpperLimit = expires + 2; + + List httpCookies = getHttpCookies(maxAge, futureDateTime); + prepareResponse(response -> { + response.setResponseCode(200); + httpCookies.forEach(httpCookie -> response.addHeader("Set-Cookie", httpCookie.toString())); + }); + + ClientHttpResponse response = connector.connect(HttpMethod.POST, this.server.url("/").uri(), + ReactiveHttpOutputMessage::setComplete).block(); + assertThat(response).isNotNull(); + assertThat(response.getCookies()).isNotEmpty(); + + List maxAgeAndExpiresCookies = response.getCookies().get(MAX_AGE_AND_EXPIRES); + assertThat(maxAgeAndExpiresCookies).size().isEqualTo(1); + Duration maxAgeAndExpires = maxAgeAndExpiresCookies.get(0).getMaxAge(); + assertThat(maxAgeAndExpires.getSeconds()).isGreaterThanOrEqualTo(maxAgeLowerLimit); + assertThat(maxAgeAndExpires.getSeconds()).isLessThanOrEqualTo(maxAgeUpperLimit); + + List maxAgeOnlyCookies = response.getCookies().get(MAX_AGE_ONLY); + assertThat(maxAgeOnlyCookies).size().isEqualTo(1); + Duration maxAgeOnly = maxAgeOnlyCookies.get(0).getMaxAge(); + assertThat(maxAgeOnly.getSeconds()).isGreaterThanOrEqualTo(maxAgeLowerLimit); + assertThat(maxAgeOnly.getSeconds()).isLessThanOrEqualTo(maxAgeUpperLimit); + + List expiresOnlyCookies = response.getCookies().get(EXPIRES_ONLY); + assertThat(expiresOnlyCookies).size().isEqualTo(1); + Duration expiresOnly = expiresOnlyCookies.get(0).getMaxAge(); + assertThat(expiresOnly.getSeconds()).isGreaterThanOrEqualTo(expiresLowerLimit); + assertThat(expiresOnly.getSeconds()).isLessThanOrEqualTo(expiresUpperLimit); + } + + private List getHttpCookies(long maxAge, ZonedDateTime futureDateTime) { + String expires = futureDateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME); + + List httpCookies = new ArrayList<>(); + + String maxAgeAndExpiresValue = String.format("; Max-Age=%d; Expires=%s", maxAge, expires); + httpCookies.add(new HttpCookie(MAX_AGE_AND_EXPIRES, maxAgeAndExpiresValue)); + + String maxAgeOnlyValue = String.format("; Max-Age=%d", maxAge); + httpCookies.add(new HttpCookie(MAX_AGE_ONLY, maxAgeOnlyValue)); + + String expiresValue = String.format("; Expires=%s", expires); + httpCookies.add(new HttpCookie(EXPIRES_ONLY, expiresValue)); + + return httpCookies; + } + private Buffer randomBody(int size) { Buffer responseBody = new Buffer(); Random rnd = new Random(); @@ -211,7 +283,9 @@ static List> connectors() { return Arrays.asList( named("Reactor Netty", new ReactorClientHttpConnector()), named("Jetty", new JettyClientHttpConnector()), - named("HttpComponents", new HttpComponentsClientHttpConnector()) + named("HttpComponents", new HttpComponentsClientHttpConnector()), + named("Jdk", new JdkClientHttpConnector()), + named("Reactor Netty 2", new ReactorNetty2ClientHttpConnector()) ); }