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}