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 */
019 package org.apache.shiro.web.servlet;
020
021 import org.apache.shiro.util.StringUtils;
022 import org.slf4j.Logger;
023 import org.slf4j.LoggerFactory;
024
025 import javax.servlet.http.HttpServletRequest;
026 import javax.servlet.http.HttpServletResponse;
027 import java.text.DateFormat;
028 import java.text.SimpleDateFormat;
029 import java.util.Calendar;
030 import java.util.Date;
031 import java.util.Locale;
032 import 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 */
042 public 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 * Formats a date into a cookie date compatible string (Netscape's specification).
333 *
334 * @param date the date to format
335 * @return an HTTP 1.0/1.1 Cookie compatible date string (GMT-based).
336 */
337 private static String toCookieDate(Date date) {
338 TimeZone tz = TimeZone.getTimeZone(GMT_TIME_ZONE_ID);
339 DateFormat fmt = new SimpleDateFormat(COOKIE_DATE_FORMAT_STRING, Locale.US);
340 fmt.setTimeZone(tz);
341 return fmt.format(date);
342 }
343
344 public void removeFrom(HttpServletRequest request, HttpServletResponse response) {
345 String name = getName();
346 String value = DELETED_COOKIE_VALUE;
347 String comment = null; //don't need to add extra size to the response - comments are irrelevant for deletions
348 String domain = getDomain();
349 String path = calculatePath(request);
350 int maxAge = 0; //always zero for deletion
351 int version = getVersion();
352 boolean secure = isSecure();
353 boolean httpOnly = false; //no need to add the extra text, plus the value 'deleteMe' is not sensitive at all
354
355 addCookieHeader(response, name, value, comment, domain, path, maxAge, version, secure, httpOnly);
356
357 log.trace("Removed '{}' cookie by setting maxAge=0", name);
358 }
359
360 public String readValue(HttpServletRequest request, HttpServletResponse ignored) {
361 String name = getName();
362 String value = null;
363 javax.servlet.http.Cookie cookie = getCookie(request, name);
364 if (cookie != null) {
365 value = cookie.getValue();
366 log.debug("Found string value [{}] from Cookie [{}]", value, name);
367 } else {
368 log.trace("No value found in request Cookies under cookie name [{}]", name);
369 }
370
371 return value;
372 }
373
374 /**
375 * Returns the cookie with the given name from the request or {@code null} if no cookie
376 * with that name could be found.
377 *
378 * @param request the current executing http request.
379 * @param cookieName the name of the cookie to find and return.
380 * @return the cookie with the given name from the request or {@code null} if no cookie
381 * with that name could be found.
382 */
383 private static javax.servlet.http.Cookie getCookie(HttpServletRequest request, String cookieName) {
384 javax.servlet.http.Cookie cookies[] = request.getCookies();
385 if (cookies != null) {
386 for (javax.servlet.http.Cookie cookie : cookies) {
387 if (cookie.getName().equals(cookieName)) {
388 return cookie;
389 }
390 }
391 }
392 return null;
393 }
394 }