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}