001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, 013 * software distributed under the License is distributed on an 014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 * KIND, either express or implied. See the License for the 016 * specific language governing permissions and limitations 017 * under the License. 018 */ 019package org.apache.shiro.web.servlet; 020 021import org.apache.shiro.util.StringUtils; 022import org.slf4j.Logger; 023import org.slf4j.LoggerFactory; 024 025import javax.servlet.http.HttpServletRequest; 026import javax.servlet.http.HttpServletResponse; 027import java.text.DateFormat; 028import java.text.SimpleDateFormat; 029import java.util.Calendar; 030import java.util.Date; 031import java.util.Locale; 032import java.util.TimeZone; 033 034/** 035 * Default {@link Cookie Cookie} implementation. 'HttpOnly' is supported out of the box, even on 036 * Servlet {@code 2.4} and {@code 2.5} container implementations, using raw header writing logic and not 037 * {@link javax.servlet.http.Cookie javax.servlet.http.Cookie} objects (which only has 'HttpOnly' support in Servlet 038 * {@code 2.6} specifications and above). 039 * 040 * @since 1.0 041 */ 042public class SimpleCookie implements Cookie { 043 044 /** 045 * {@code -1}, indicating the cookie should expire when the browser closes. 046 */ 047 public static final int DEFAULT_MAX_AGE = -1; 048 049 /** 050 * {@code -1} indicating that no version property should be set on the cookie. 051 */ 052 public static final int DEFAULT_VERSION = -1; 053 054 //These constants are protected on purpose so that the test case can use them 055 protected static final String NAME_VALUE_DELIMITER = "="; 056 protected static final String ATTRIBUTE_DELIMITER = "; "; 057 protected static final long DAY_MILLIS = 86400000; //1 day = 86,400,000 milliseconds 058 protected static final String GMT_TIME_ZONE_ID = "GMT"; 059 protected static final String COOKIE_DATE_FORMAT_STRING = "EEE, dd-MMM-yyyy HH:mm:ss z"; 060 061 protected static final String COOKIE_HEADER_NAME = "Set-Cookie"; 062 protected static final String PATH_ATTRIBUTE_NAME = "Path"; 063 protected static final String EXPIRES_ATTRIBUTE_NAME = "Expires"; 064 protected static final String MAXAGE_ATTRIBUTE_NAME = "Max-Age"; 065 protected static final String DOMAIN_ATTRIBUTE_NAME = "Domain"; 066 protected static final String VERSION_ATTRIBUTE_NAME = "Version"; 067 protected static final String COMMENT_ATTRIBUTE_NAME = "Comment"; 068 protected static final String SECURE_ATTRIBUTE_NAME = "Secure"; 069 protected static final String HTTP_ONLY_ATTRIBUTE_NAME = "HttpOnly"; 070 071 private static final transient Logger log = LoggerFactory.getLogger(SimpleCookie.class); 072 073 private String name; 074 private String value; 075 private String comment; 076 private String domain; 077 private String path; 078 private int maxAge; 079 private int version; 080 private boolean secure; 081 private boolean httpOnly; 082 083 public SimpleCookie() { 084 this.maxAge = DEFAULT_MAX_AGE; 085 this.version = DEFAULT_VERSION; 086 this.httpOnly = true; //most of the cookies ever used by Shiro should be as secure as possible. 087 } 088 089 public SimpleCookie(String name) { 090 this(); 091 this.name = name; 092 } 093 094 public SimpleCookie(Cookie cookie) { 095 this.name = cookie.getName(); 096 this.value = cookie.getValue(); 097 this.comment = cookie.getComment(); 098 this.domain = cookie.getDomain(); 099 this.path = cookie.getPath(); 100 this.maxAge = Math.max(DEFAULT_MAX_AGE, cookie.getMaxAge()); 101 this.version = Math.max(DEFAULT_VERSION, cookie.getVersion()); 102 this.secure = cookie.isSecure(); 103 this.httpOnly = cookie.isHttpOnly(); 104 } 105 106 public String getName() { 107 return name; 108 } 109 110 public void setName(String name) { 111 if (!StringUtils.hasText(name)) { 112 throw new IllegalArgumentException("Name cannot be null/empty."); 113 } 114 this.name = name; 115 } 116 117 public String getValue() { 118 return value; 119 } 120 121 public void setValue(String value) { 122 this.value = value; 123 } 124 125 public String getComment() { 126 return comment; 127 } 128 129 public void setComment(String comment) { 130 this.comment = comment; 131 } 132 133 public String getDomain() { 134 return domain; 135 } 136 137 public void setDomain(String domain) { 138 this.domain = domain; 139 } 140 141 public String getPath() { 142 return path; 143 } 144 145 public void setPath(String path) { 146 this.path = path; 147 } 148 149 public int getMaxAge() { 150 return maxAge; 151 } 152 153 public void setMaxAge(int maxAge) { 154 this.maxAge = Math.max(DEFAULT_MAX_AGE, maxAge); 155 } 156 157 public int getVersion() { 158 return version; 159 } 160 161 public void setVersion(int version) { 162 this.version = Math.max(DEFAULT_VERSION, version); 163 } 164 165 public boolean isSecure() { 166 return secure; 167 } 168 169 public void setSecure(boolean secure) { 170 this.secure = secure; 171 } 172 173 public boolean isHttpOnly() { 174 return httpOnly; 175 } 176 177 public void setHttpOnly(boolean httpOnly) { 178 this.httpOnly = httpOnly; 179 } 180 181 /** 182 * Returns the Cookie's calculated path setting. If the {@link javax.servlet.http.Cookie#getPath() path} is {@code null}, then the 183 * {@code request}'s {@link javax.servlet.http.HttpServletRequest#getContextPath() context path} 184 * will be returned. If getContextPath() is the empty string or null then the ROOT_PATH constant is returned. 185 * 186 * @param request the incoming HttpServletRequest 187 * @return the path to be used as the path when the cookie is created or removed 188 */ 189 private String calculatePath(HttpServletRequest request) { 190 String path = StringUtils.clean(getPath()); 191 if (!StringUtils.hasText(path)) { 192 path = StringUtils.clean(request.getContextPath()); 193 } 194 195 //fix for http://issues.apache.org/jira/browse/SHIRO-9: 196 if (path == null) { 197 path = ROOT_PATH; 198 } 199 log.trace("calculated path: {}", path); 200 return path; 201 } 202 203 public void saveTo(HttpServletRequest request, HttpServletResponse response) { 204 205 String name = getName(); 206 String value = getValue(); 207 String comment = getComment(); 208 String domain = getDomain(); 209 String path = calculatePath(request); 210 int maxAge = getMaxAge(); 211 int version = getVersion(); 212 boolean secure = isSecure(); 213 boolean httpOnly = isHttpOnly(); 214 215 addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly); 216 } 217 218 private void addCookieHeader(HttpServletResponse response, String name, String value, String comment, 219 String domain, String path, int maxAge, int version, 220 boolean secure, boolean httpOnly) { 221 222 String headerValue = buildHeaderValue(name, value, comment, domain, path, maxAge, version, secure, httpOnly); 223 response.addHeader(COOKIE_HEADER_NAME, headerValue); 224 225 if (log.isDebugEnabled()) { 226 log.debug("Added HttpServletResponse Cookie [{}]", headerValue); 227 } 228 } 229 230 /* 231 * This implementation followed the grammar defined here for convenience: 232 * <a href="http://github.com/abarth/http-state/blob/master/notes/2009-11-07-Yui-Naruse.txt">Cookie grammar</a>. 233 * 234 * @return the 'Set-Cookie' header value for this cookie instance. 235 */ 236 237 protected String buildHeaderValue(String name, String value, String comment, 238 String domain, String path, int maxAge, int version, 239 boolean secure, boolean httpOnly) { 240 241 if (!StringUtils.hasText(name)) { 242 throw new IllegalStateException("Cookie name cannot be null/empty."); 243 } 244 245 StringBuilder sb = new StringBuilder(name).append(NAME_VALUE_DELIMITER); 246 247 if (StringUtils.hasText(value)) { 248 sb.append(value); 249 } 250 251 appendComment(sb, comment); 252 appendDomain(sb, domain); 253 appendPath(sb, path); 254 appendExpires(sb, maxAge); 255 appendVersion(sb, version); 256 appendSecure(sb, secure); 257 appendHttpOnly(sb, httpOnly); 258 259 return sb.toString(); 260 261 } 262 263 private void appendComment(StringBuilder sb, String comment) { 264 if (StringUtils.hasText(comment)) { 265 sb.append(ATTRIBUTE_DELIMITER); 266 sb.append(COMMENT_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(comment); 267 } 268 } 269 270 private void appendDomain(StringBuilder sb, String domain) { 271 if (StringUtils.hasText(domain)) { 272 sb.append(ATTRIBUTE_DELIMITER); 273 sb.append(DOMAIN_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(domain); 274 } 275 } 276 277 private void appendPath(StringBuilder sb, String path) { 278 if (StringUtils.hasText(path)) { 279 sb.append(ATTRIBUTE_DELIMITER); 280 sb.append(PATH_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(path); 281 } 282 } 283 284 private void appendExpires(StringBuilder sb, int maxAge) { 285 // if maxAge is negative, cookie should should expire when browser closes 286 // Don't write the maxAge cookie value if it's negative - at least on Firefox it'll cause the 287 // cookie to be deleted immediately 288 // Write the expires header used by older browsers, but may be unnecessary 289 // and it is not by the spec, see http://www.faqs.org/rfcs/rfc2965.html 290 // TODO consider completely removing the following 291 if (maxAge >= 0) { 292 sb.append(ATTRIBUTE_DELIMITER); 293 sb.append(MAXAGE_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(maxAge); 294 sb.append(ATTRIBUTE_DELIMITER); 295 Date expires; 296 if (maxAge == 0) { 297 //delete the cookie by specifying a time in the past (1 day ago): 298 expires = new Date(System.currentTimeMillis() - DAY_MILLIS); 299 } else { 300 //Value is in seconds. So take 'now' and add that many seconds, and that's our expiration date: 301 Calendar cal = Calendar.getInstance(); 302 cal.add(Calendar.SECOND, maxAge); 303 expires = cal.getTime(); 304 } 305 String formatted = toCookieDate(expires); 306 sb.append(EXPIRES_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(formatted); 307 } 308 } 309 310 private void appendVersion(StringBuilder sb, int version) { 311 if (version > DEFAULT_VERSION) { 312 sb.append(ATTRIBUTE_DELIMITER); 313 sb.append(VERSION_ATTRIBUTE_NAME).append(NAME_VALUE_DELIMITER).append(version); 314 } 315 } 316 317 private void appendSecure(StringBuilder sb, boolean secure) { 318 if (secure) { 319 sb.append(ATTRIBUTE_DELIMITER); 320 sb.append(SECURE_ATTRIBUTE_NAME); //No value for this attribute 321 } 322 } 323 324 private void appendHttpOnly(StringBuilder sb, boolean httpOnly) { 325 if (httpOnly) { 326 sb.append(ATTRIBUTE_DELIMITER); 327 sb.append(HTTP_ONLY_ATTRIBUTE_NAME); //No value for this attribute 328 } 329 } 330 331 /** 332 * Check whether the given {@code cookiePath} matches the {@code requestPath} 333 * 334 * @param cookiePath 335 * @param requestPath 336 * @return 337 * @see <a href="https://tools.ietf.org/html/rfc6265#section-5.1.4">RFC 6265, Section 5.1.4 "Paths and Path-Match"</a> 338 */ 339 private boolean pathMatches(String cookiePath, String requestPath) { 340 if (!requestPath.startsWith(cookiePath)) { 341 return false; 342 } 343 344 return requestPath.length() == cookiePath.length() 345 || cookiePath.charAt(cookiePath.length() - 1) == '/' 346 || requestPath.charAt(cookiePath.length()) == '/'; 347 } 348 349 /** 350 * Formats a date into a cookie date compatible string (Netscape's specification). 351 * 352 * @param date the date to format 353 * @return an HTTP 1.0/1.1 Cookie compatible date string (GMT-based). 354 */ 355 private static String toCookieDate(Date date) { 356 TimeZone tz = TimeZone.getTimeZone(GMT_TIME_ZONE_ID); 357 DateFormat fmt = new SimpleDateFormat(COOKIE_DATE_FORMAT_STRING, Locale.US); 358 fmt.setTimeZone(tz); 359 return fmt.format(date); 360 } 361 362 public void removeFrom(HttpServletRequest request, HttpServletResponse response) { 363 String name = getName(); 364 String value = DELETED_COOKIE_VALUE; 365 String comment = null; //don't need to add extra size to the response - comments are irrelevant for deletions 366 String domain = getDomain(); 367 String path = calculatePath(request); 368 int maxAge = 0; //always zero for deletion 369 int version = getVersion(); 370 boolean secure = isSecure(); 371 boolean httpOnly = false; //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all 372 373 addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly); 374 375 log.trace("Removed '{}' cookie by setting maxAge=0", name); 376 } 377 378 public String readValue(HttpServletRequest request, HttpServletResponse ignored) { 379 String name = getName(); 380 String value = null; 381 javax.servlet.http.Cookie cookie = getCookie(request, name); 382 if (cookie != null) { 383 // Validate that the cookie is used at the correct place. 384 String path = StringUtils.clean(getPath()); 385 if (path != null && !pathMatches(path, request.getRequestURI())) { 386 log.warn("Found '{}' cookie at path '{}', but should be only used for '{}'", new Object[] { name, request.getRequestURI(), path}); 387 } else { 388 value = cookie.getValue(); 389 log.debug("Found '{}' cookie value [{}]", name, value); 390 } 391 } else { 392 log.trace("No '{}' cookie value", name); 393 } 394 395 return value; 396 } 397 398 /** 399 * Returns the cookie with the given name from the request or {@code null} if no cookie 400 * with that name could be found. 401 * 402 * @param request the current executing http request. 403 * @param cookieName the name of the cookie to find and return. 404 * @return the cookie with the given name from the request or {@code null} if no cookie 405 * with that name could be found. 406 */ 407 private static javax.servlet.http.Cookie getCookie(HttpServletRequest request, String cookieName) { 408 javax.servlet.http.Cookie cookies[] = request.getCookies(); 409 if (cookies != null) { 410 for (javax.servlet.http.Cookie cookie : cookies) { 411 if (cookie.getName().equals(cookieName)) { 412 return cookie; 413 } 414 } 415 } 416 return null; 417 } 418}