View Javadoc

1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *     http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.shiro.web.servlet;
20  
21  import org.apache.shiro.util.StringUtils;
22  import org.slf4j.Logger;
23  import org.slf4j.LoggerFactory;
24  
25  import javax.servlet.http.HttpServletRequest;
26  import javax.servlet.http.HttpServletResponse;
27  import java.text.DateFormat;
28  import java.text.SimpleDateFormat;
29  import java.util.Calendar;
30  import java.util.Date;
31  import java.util.Locale;
32  import java.util.TimeZone;
33  
34  /**
35   * Default {@link Cookie Cookie} implementation.  'HttpOnly' is supported out of the box, even on
36   * Servlet {@code 2.4} and {@code 2.5} container implementations, using raw header writing logic and not
37   * {@link javax.servlet.http.Cookie javax.servlet.http.Cookie} objects (which only has 'HttpOnly' support in Servlet
38   * {@code 2.6} specifications and above).
39   *
40   * @since 1.0
41   */
42  public class SimpleCookie implements Cookie {
43  
44      /**
45       * {@code -1}, indicating the cookie should expire when the browser closes.
46       */
47      public static final int DEFAULT_MAX_AGE = -1;
48  
49      /**
50       * {@code -1} indicating that no version property should be set on the cookie.
51       */
52      public static final int DEFAULT_VERSION = -1;
53  
54      //These constants are protected on purpose so that the test case can use them
55      protected static final String NAME_VALUE_DELIMITER = "=";
56      protected static final String ATTRIBUTE_DELIMITER = "; ";
57      protected static final long DAY_MILLIS = 86400000; //1 day = 86,400,000 milliseconds
58      protected static final String GMT_TIME_ZONE_ID = "GMT";
59      protected static final String COOKIE_DATE_FORMAT_STRING = "EEE, dd-MMM-yyyy HH:mm:ss z";
60  
61      protected static final String COOKIE_HEADER_NAME = "Set-Cookie";
62      protected static final String PATH_ATTRIBUTE_NAME = "Path";
63      protected static final String EXPIRES_ATTRIBUTE_NAME = "Expires";
64      protected static final String MAXAGE_ATTRIBUTE_NAME = "Max-Age";
65      protected static final String DOMAIN_ATTRIBUTE_NAME = "Domain";
66      protected static final String VERSION_ATTRIBUTE_NAME = "Version";
67      protected static final String COMMENT_ATTRIBUTE_NAME = "Comment";
68      protected static final String SECURE_ATTRIBUTE_NAME = "Secure";
69      protected static final String HTTP_ONLY_ATTRIBUTE_NAME = "HttpOnly";
70  
71      private static final transient Logger log = LoggerFactory.getLogger(SimpleCookie.class);
72  
73      private String name;
74      private String value;
75      private String comment;
76      private String domain;
77      private String path;
78      private int maxAge;
79      private int version;
80      private boolean secure;
81      private boolean httpOnly;
82  
83      public SimpleCookie() {
84          this.maxAge = DEFAULT_MAX_AGE;
85          this.version = DEFAULT_VERSION;
86          this.httpOnly = true; //most of the cookies ever used by Shiro should be as secure as possible.
87      }
88  
89      public SimpleCookie(String name) {
90          this();
91          this.name = name;
92      }
93  
94      public SimpleCookie(Cookie cookie) {
95          this.name = cookie.getName();
96          this.value = cookie.getValue();
97          this.comment = cookie.getComment();
98          this.domain = cookie.getDomain();
99          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 }