001/*
002 * Copyright 2002-2020 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.http;
018
019import java.time.Duration;
020
021import org.springframework.lang.Nullable;
022import org.springframework.util.Assert;
023import org.springframework.util.ObjectUtils;
024import org.springframework.util.StringUtils;
025
026/**
027 * An {@code HttpCookie} subclass with the additional attributes allowed in
028 * the "Set-Cookie" response header. To build an instance use the {@link #from}
029 * static method.
030 *
031 * @author Rossen Stoyanchev
032 * @author Brian Clozel
033 * @since 5.0
034 * @see <a href="https://tools.ietf.org/html/rfc6265">RFC 6265</a>
035 */
036public final class ResponseCookie extends HttpCookie {
037
038        private final Duration maxAge;
039
040        @Nullable
041        private final String domain;
042
043        @Nullable
044        private final String path;
045
046        private final boolean secure;
047
048        private final boolean httpOnly;
049
050        @Nullable
051        private final String sameSite;
052
053
054        /**
055         * Private constructor. See {@link #from(String, String)}.
056         */
057        private ResponseCookie(String name, String value, Duration maxAge, @Nullable String domain,
058                        @Nullable String path, boolean secure, boolean httpOnly, @Nullable String sameSite) {
059
060                super(name, value);
061                Assert.notNull(maxAge, "Max age must not be null");
062
063                this.maxAge = maxAge;
064                this.domain = domain;
065                this.path = path;
066                this.secure = secure;
067                this.httpOnly = httpOnly;
068                this.sameSite = sameSite;
069
070                Rfc6265Utils.validateCookieName(name);
071                Rfc6265Utils.validateCookieValue(value);
072                Rfc6265Utils.validateDomain(domain);
073                Rfc6265Utils.validatePath(path);
074        }
075
076
077        /**
078         * Return the cookie "Max-Age" attribute in seconds.
079         * <p>A positive value indicates when the cookie expires relative to the
080         * current time. A value of 0 means the cookie should expire immediately.
081         * A negative value means no "Max-Age" attribute in which case the cookie
082         * is removed when the browser is closed.
083         */
084        public Duration getMaxAge() {
085                return this.maxAge;
086        }
087
088        /**
089         * Return the cookie "Domain" attribute, or {@code null} if not set.
090         */
091        @Nullable
092        public String getDomain() {
093                return this.domain;
094        }
095
096        /**
097         * Return the cookie "Path" attribute, or {@code null} if not set.
098         */
099        @Nullable
100        public String getPath() {
101                return this.path;
102        }
103
104        /**
105         * Return {@code true} if the cookie has the "Secure" attribute.
106         */
107        public boolean isSecure() {
108                return this.secure;
109        }
110
111        /**
112         * Return {@code true} if the cookie has the "HttpOnly" attribute.
113         * @see <a href="https://www.owasp.org/index.php/HTTPOnly">https://www.owasp.org/index.php/HTTPOnly</a>
114         */
115        public boolean isHttpOnly() {
116                return this.httpOnly;
117        }
118
119        /**
120         * Return the cookie "SameSite" attribute, or {@code null} if not set.
121         * <p>This limits the scope of the cookie such that it will only be attached to
122         * same site requests if {@code "Strict"} or cross-site requests if {@code "Lax"}.
123         * @see <a href="https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis#section-4.1.2.7">RFC6265 bis</a>
124         * @since 5.1
125         */
126        @Nullable
127        public String getSameSite() {
128                return this.sameSite;
129        }
130
131
132        @Override
133        public boolean equals(@Nullable Object other) {
134                if (this == other) {
135                        return true;
136                }
137                if (!(other instanceof ResponseCookie)) {
138                        return false;
139                }
140                ResponseCookie otherCookie = (ResponseCookie) other;
141                return (getName().equalsIgnoreCase(otherCookie.getName()) &&
142                                ObjectUtils.nullSafeEquals(this.path, otherCookie.getPath()) &&
143                                ObjectUtils.nullSafeEquals(this.domain, otherCookie.getDomain()));
144        }
145
146        @Override
147        public int hashCode() {
148                int result = super.hashCode();
149                result = 31 * result + ObjectUtils.nullSafeHashCode(this.domain);
150                result = 31 * result + ObjectUtils.nullSafeHashCode(this.path);
151                return result;
152        }
153
154        @Override
155        public String toString() {
156                StringBuilder sb = new StringBuilder();
157                sb.append(getName()).append('=').append(getValue());
158                if (StringUtils.hasText(getPath())) {
159                        sb.append("; Path=").append(getPath());
160                }
161                if (StringUtils.hasText(this.domain)) {
162                        sb.append("; Domain=").append(this.domain);
163                }
164                if (!this.maxAge.isNegative()) {
165                        sb.append("; Max-Age=").append(this.maxAge.getSeconds());
166                        sb.append("; Expires=");
167                        long millis = this.maxAge.getSeconds() > 0 ? System.currentTimeMillis() + this.maxAge.toMillis() : 0;
168                        sb.append(HttpHeaders.formatDate(millis));
169                }
170                if (this.secure) {
171                        sb.append("; Secure");
172                }
173                if (this.httpOnly) {
174                        sb.append("; HttpOnly");
175                }
176                if (StringUtils.hasText(this.sameSite)) {
177                        sb.append("; SameSite=").append(this.sameSite);
178                }
179                return sb.toString();
180        }
181
182
183        /**
184         * Factory method to obtain a builder for a server-defined cookie that starts
185         * with a name-value pair and may also include attributes.
186         * @param name the cookie name
187         * @param value the cookie value
188         * @return a builder to create the cookie with
189         */
190        public static ResponseCookieBuilder from(final String name, final String value) {
191                return from(name, value, false);
192        }
193
194        /**
195         * Factory method to obtain a builder for a server-defined cookie. Unlike
196         * {@link #from(String, String)} this option assumes input from a remote
197         * server, which can be handled more leniently, e.g. ignoring a empty domain
198         * name with double quotes.
199         * @param name the cookie name
200         * @param value the cookie value
201         * @return a builder to create the cookie with
202         * @since 5.2.5
203         */
204        public static ResponseCookieBuilder fromClientResponse(final String name, final String value) {
205                return from(name, value, true);
206        }
207
208
209        private static ResponseCookieBuilder from(final String name, final String value, boolean lenient) {
210
211                return new ResponseCookieBuilder() {
212
213                        private Duration maxAge = Duration.ofSeconds(-1);
214
215                        @Nullable
216                        private String domain;
217
218                        @Nullable
219                        private String path;
220
221                        private boolean secure;
222
223                        private boolean httpOnly;
224
225                        @Nullable
226                        private String sameSite;
227
228                        @Override
229                        public ResponseCookieBuilder maxAge(Duration maxAge) {
230                                this.maxAge = maxAge;
231                                return this;
232                        }
233
234                        @Override
235                        public ResponseCookieBuilder maxAge(long maxAgeSeconds) {
236                                this.maxAge = maxAgeSeconds >= 0 ? Duration.ofSeconds(maxAgeSeconds) : Duration.ofSeconds(-1);
237                                return this;
238                        }
239
240                        @Override
241                        public ResponseCookieBuilder domain(String domain) {
242                                this.domain = initDomain(domain);
243                                return this;
244                        }
245
246                        @Nullable
247                        private String initDomain(String domain) {
248                                if (lenient && StringUtils.hasLength(domain)) {
249                                        String str = domain.trim();
250                                        if (str.startsWith("\"") && str.endsWith("\"")) {
251                                                if (str.substring(1, str.length() - 1).trim().isEmpty()) {
252                                                        return null;
253                                                }
254                                        }
255                                }
256                                return domain;
257                        }
258
259                        @Override
260                        public ResponseCookieBuilder path(String path) {
261                                this.path = path;
262                                return this;
263                        }
264
265                        @Override
266                        public ResponseCookieBuilder secure(boolean secure) {
267                                this.secure = secure;
268                                return this;
269                        }
270
271                        @Override
272                        public ResponseCookieBuilder httpOnly(boolean httpOnly) {
273                                this.httpOnly = httpOnly;
274                                return this;
275                        }
276
277                        @Override
278                        public ResponseCookieBuilder sameSite(@Nullable String sameSite) {
279                                this.sameSite = sameSite;
280                                return this;
281                        }
282
283                        @Override
284                        public ResponseCookie build() {
285                                return new ResponseCookie(name, value, this.maxAge, this.domain, this.path,
286                                                this.secure, this.httpOnly, this.sameSite);
287                        }
288                };
289        }
290
291
292        /**
293         * A builder for a server-defined HttpCookie with attributes.
294         */
295        public interface ResponseCookieBuilder {
296
297                /**
298                 * Set the cookie "Max-Age" attribute.
299                 *
300                 * <p>A positive value indicates when the cookie should expire relative
301                 * to the current time. A value of 0 means the cookie should expire
302                 * immediately. A negative value results in no "Max-Age" attribute in
303                 * which case the cookie is removed when the browser is closed.
304                 */
305                ResponseCookieBuilder maxAge(Duration maxAge);
306
307                /**
308                 * Variant of {@link #maxAge(Duration)} accepting a value in seconds.
309                 */
310                ResponseCookieBuilder maxAge(long maxAgeSeconds);
311
312                /**
313                 * Set the cookie "Path" attribute.
314                 */
315                ResponseCookieBuilder path(String path);
316
317                /**
318                 * Set the cookie "Domain" attribute.
319                 */
320                ResponseCookieBuilder domain(String domain);
321
322                /**
323                 * Add the "Secure" attribute to the cookie.
324                 */
325                ResponseCookieBuilder secure(boolean secure);
326
327                /**
328                 * Add the "HttpOnly" attribute to the cookie.
329                 * @see <a href="https://www.owasp.org/index.php/HTTPOnly">https://www.owasp.org/index.php/HTTPOnly</a>
330                 */
331                ResponseCookieBuilder httpOnly(boolean httpOnly);
332
333                /**
334                 * Add the "SameSite" attribute to the cookie.
335                 * <p>This limits the scope of the cookie such that it will only be
336                 * attached to same site requests if {@code "Strict"} or cross-site
337                 * requests if {@code "Lax"}.
338                 * @since 5.1
339                 * @see <a href="https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis#section-4.1.2.7">RFC6265 bis</a>
340                 */
341                ResponseCookieBuilder sameSite(@Nullable String sameSite);
342
343                /**
344                 * Create the HttpCookie.
345                 */
346                ResponseCookie build();
347        }
348
349
350        private static class Rfc6265Utils {
351
352                private static final String SEPARATOR_CHARS = new String(new char[] {
353                                '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' '
354                });
355
356                private static final String DOMAIN_CHARS =
357                                "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-";
358
359
360                public static void validateCookieName(String name) {
361                        for (int i = 0; i < name.length(); i++) {
362                                char c = name.charAt(i);
363                                // CTL = <US-ASCII control chars (octets 0 - 31) and DEL (127)>
364                                if (c <= 0x1F || c == 0x7F) {
365                                        throw new IllegalArgumentException(
366                                                        name + ": RFC2616 token cannot have control chars");
367                                }
368                                if (SEPARATOR_CHARS.indexOf(c) >= 0) {
369                                        throw new IllegalArgumentException(
370                                                        name + ": RFC2616 token cannot have separator chars such as '" + c + "'");
371                                }
372                                if (c >= 0x80) {
373                                        throw new IllegalArgumentException(
374                                                        name + ": RFC2616 token can only have US-ASCII: 0x" + Integer.toHexString(c));
375                                }
376                        }
377                }
378
379                public static void validateCookieValue(@Nullable String value) {
380                        if (value == null) {
381                                return;
382                        }
383                        int start = 0;
384                        int end = value.length();
385                        if (end > 1 && value.charAt(0) == '"' && value.charAt(end - 1) == '"') {
386                                start = 1;
387                                end--;
388                        }
389                        char[] chars = value.toCharArray();
390                        for (int i = start; i < end; i++) {
391                                char c = chars[i];
392                                if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c || c == 0x7f) {
393                                        throw new IllegalArgumentException(
394                                                        "RFC2616 cookie value cannot have '" + c + "'");
395                                }
396                                if (c >= 0x80) {
397                                        throw new IllegalArgumentException(
398                                                        "RFC2616 cookie value can only have US-ASCII chars: 0x" + Integer.toHexString(c));
399                                }
400                        }
401                }
402
403                public static void validateDomain(@Nullable String domain) {
404                        if (!StringUtils.hasLength(domain)) {
405                                return;
406                        }
407                        int char1 = domain.charAt(0);
408                        int charN = domain.charAt(domain.length() - 1);
409                        if (char1 == '-' || charN == '.' || charN == '-') {
410                                throw new IllegalArgumentException("Invalid first/last char in cookie domain: " + domain);
411                        }
412                        for (int i = 0, c = -1; i < domain.length(); i++) {
413                                int p = c;
414                                c = domain.charAt(i);
415                                if (DOMAIN_CHARS.indexOf(c) == -1 || (p == '.' && (c == '.' || c == '-')) || (p == '-' && c == '.')) {
416                                        throw new IllegalArgumentException(domain + ": invalid cookie domain char '" + c + "'");
417                                }
418                        }
419                }
420
421                public static void validatePath(@Nullable String path) {
422                        if (path == null) {
423                                return;
424                        }
425                        for (int i = 0; i < path.length(); i++) {
426                                char c = path.charAt(i);
427                                if (c < 0x20 || c > 0x7E || c == ';') {
428                                        throw new IllegalArgumentException(path + ": Invalid cookie path char '" + c + "'");
429                                }
430                        }
431                }
432        }
433
434}