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 '{}' cookie value [{}]", name, value);
367            } else {
368                log.trace("No '{}' cookie value", 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    }