diff --git a/doc/api/cookies.rst b/doc/api/cookies.rst new file mode 100644 index 0000000..42c5463 --- /dev/null +++ b/doc/api/cookies.rst @@ -0,0 +1,85 @@ + +.. _cookies: +Cookies +------------- + +Cookie support has been added to falcon from version 0.3. + +.. _getting-cookies: +Getting Cookies +~~~~~~~~~~~~~ + +Cookie can be read from a request via the :py:attr:`~.Request.cookies` request attribute: + + +.. code:: python + + class Resource(object): + def on_get(self, req, resp): + + cookies = req.cookies + + if "my_cookie" in cookies: + my_cookie_value = cookies["my_cookie"] + # .... + +The :py:attr:`~.Request.cookies` attribute is a regular +:py:class:`dict` object. + +.. tip :: :py:attr:`~.Request.cookies` returns a + copy of the response cookie dict. Assign it to a variable as in the above example + for better performance. + +.. _setting-cookies: +Setting Cookies +~~~~~~~~~~~~~~~ + +Setting cookies on a response is done via the :py:meth:`~.Response.set_cookie`. + +You should use :py:meth:`~.Response.set_cookie` instead of +:py:meth:`~.Response.set_header` or :py:meth:`~.Response.append_header`. + +With :py:meth:`~.Response.set_header` you cannot set multiple headers +with the same name (which is how multiple cookies are sent to the client). + +:py:meth:`~.Response.append_header` appends multiple values to the same +header field, which is not compatible with the format used by `Set-Cookie` +headers to send cookies to clients. + + + +Simple example: + +.. code:: python + + class Resource(object): + def on_get(self, req, resp): + # Set the cookie "my_cookie" to the value "my cookie value" + resp.set_cookie("my_cookie", "my cookie value") + + +You can of course also set the domain, path and lifetime of the cookie. + + + + +.. code:: python + + class Resource(object): + def on_get(self, req, resp): + # Set the 'max-age' of the cookie to 10 minutes (600 seconds) + # and the cookies domain to "example.com" + resp.set_cookie("my_cookie", "my cookie value", + max_age=600, domain="example.com") + + +If you set a cookie and want to get rid of it again, you can +use the :py:meth:`~.Response.unset_cookie`: + +.. code:: python + + class Resource(object): + def on_get(self, req, resp): + resp.set_cookie("bad_cookie", ":(") + # clear the bad cookie + resp.unset_cookie("bad_cookie") diff --git a/doc/api/index.rst b/doc/api/index.rst index 7a8fea3..3d07c75 100644 --- a/doc/api/index.rst +++ b/doc/api/index.rst @@ -6,6 +6,7 @@ Classes and Functions api request_and_response + cookies status errors middleware diff --git a/doc/user/tutorial.rst b/doc/user/tutorial.rst index 36c7d8d..b89ae09 100644 --- a/doc/user/tutorial.rst +++ b/doc/user/tutorial.rst @@ -466,7 +466,6 @@ Query Strings *Coming soon...* - Introducing Hooks ----------------- diff --git a/falcon/request.py b/falcon/request.py index 0adc8e8..942878c 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -1,5 +1,3 @@ -# Copyright 2013 by Rackspace Hosting, Inc. -# # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at @@ -34,6 +32,8 @@ from falcon import util from falcon.util import uri from falcon import request_helpers as helpers +from six.moves.http_cookies import SimpleCookie + DEFAULT_ERROR_LOG_FORMAT = (u'{0:%Y-%m-%d %H:%M:%S} [FALCON] [ERROR]' u' {1} {2}{3} => ') @@ -167,6 +167,11 @@ class Request(object): all the values in the order seen. options (dict): Set of global options passed from the API handler. + + cookies (dict): + A dict of name/value cookie pairs. + See also: :ref:`Getting Cookies ` + """ __slots__ = ( @@ -183,6 +188,7 @@ class Request(object): 'context', '_wsgierrors', 'options', + '_cookies', ) # Allow child classes to override this @@ -231,6 +237,8 @@ class Request(object): else: self.query_string = six.text_type() + self._cookies = None + self._cached_headers = None self._cached_uri = None self._cached_relative_uri = None @@ -475,6 +483,21 @@ class Request(object): def params(self): return self._params + @property + def cookies(self): + if self._cookies is None: + # NOTE(tbug): We might want to look into parsing + # cookies ourselves. The SimpleCookie is doing a + # lot if stuff only required to SEND cookies. + parser = SimpleCookie(self.get_header("Cookie")) + cookies = {} + for morsel in parser.values(): + cookies[morsel.key] = morsel.value + + self._cookies = cookies + + return self._cookies.copy() + # ------------------------------------------------------------------------ # Methods # ------------------------------------------------------------------------ diff --git a/falcon/response.py b/falcon/response.py index 4ffb6e4..b5d1b2a 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -14,8 +14,12 @@ import six -from falcon.response_helpers import header_property, format_range -from falcon.util import dt_to_http, uri +from falcon.response_helpers import header_property, format_range,\ + is_ascii_encodable +from falcon.util import dt_to_http, uri, TimezoneGMT +from six.moves.http_cookies import SimpleCookie, CookieError + +GMT_TIMEZONE = TimezoneGMT() class Response(object): @@ -79,6 +83,7 @@ class Response(object): '_body_encoded', # Stuff 'data', '_headers', + '_cookies', 'status', 'stream', 'stream_len' @@ -88,6 +93,10 @@ class Response(object): self.status = '200 OK' self._headers = {} + # NOTE(tbug): will be set to a SimpleCookie object + # when cookie is set via set_cookie + self._cookies = None + self._body = None self._body_encoded = None self.data = None @@ -136,12 +145,120 @@ class Response(object): self.stream = stream self.stream_len = stream_len + def set_cookie(self, name, value, expires=None, max_age=None, + domain=None, path=None, secure=True, http_only=True): + """Set a response cookie. + + Note: + :meth:`~.set_cookie` is capable of setting multiple cookies + with specified cookie properties on the same request. + Currently :meth:`~.set_header` and :meth:`~.append_header` are + not capable of this. + + Args: + name (str): + Cookie name + value (str): + Cookie value + expires (datetime): Defines when the cookie should expire. + the default is to expire when the browser is closed. + max_age (int): The Max-Age attribute defines the lifetime of the + cookie, in seconds. The delta-seconds value is a decimal non- + negative integer. After delta-seconds seconds elapse, + the client should discard the cookie. + domain (str): The Domain attribute specifies the domain for + which the cookie is valid. + An explicitly specified domain must always start with a dot. + A value of 0 means the cookie should be discarded immediately. + path (str): The Path attribute specifies the subset of URLs to + which this cookie applies. + secure (bool) (default: True): The Secure attribute directs the + user agent to use only secure means to contact the origin + server whenever it sends back this cookie. + Warning: You will also need to enforce HTTPS for the cookies + to be transfered securely. + http_only (bool) (default: True): + The attribute http_only specifies that the cookie + is only transferred in HTTP requests, and is not accessible + through JavaScript. This is intended to mitigate some forms + of cross-site scripting. + Note: + Kwargs and their valid values are all + specified in http://tools.ietf.org/html/rfc6265 + + See Also: + :ref:`Setting Cookies ` + + Raises: + KeyError if ``name`` is not a valid cookie name. + + """ + + if not is_ascii_encodable(name): + raise KeyError('"name" is not ascii encodable') + if not is_ascii_encodable(value): + raise ValueError('"value" is not ascii encodable') + + if six.PY2: # pragma: no cover + name = str(name) + value = str(value) + + if self._cookies is None: + self._cookies = SimpleCookie() + + try: + self._cookies[name] = value + except CookieError as e: # pragma: no cover + # NOTE(tbug): we raise a KeyError here, to avoid leaking + # the CookieError to the user. SimpleCookie (well, BaseCookie) + # only throws CookieError on issues with the cookie key + raise KeyError(str(e)) + + if expires: + # set Expires on cookie. Format is Wdy, DD Mon YYYY HH:MM:SS GMT + + # NOTE(tbug): we never actually need to + # know that GMT is named GMT when formatting cookies. + # It is a function call less to just write "GMT" in the fmt string: + fmt = "%a, %d %b %Y %H:%M:%S GMT" + if expires.tzinfo is None: + # naive + self._cookies[name]["expires"] = expires.strftime(fmt) + else: + # aware + gmt_expires = expires.astimezone(GMT_TIMEZONE) + self._cookies[name]["expires"] = gmt_expires.strftime(fmt) + + if max_age: + self._cookies[name]["max-age"] = max_age + + if domain: + self._cookies[name]["domain"] = domain + + if path: + self._cookies[name]["path"] = path + + if secure: + self._cookies[name]["secure"] = secure + + if http_only: + self._cookies[name]["httponly"] = http_only + + def unset_cookie(self, name): + """Unset a cookie from the response + """ + if self._cookies is not None and name in self._cookies: + del self._cookies[name] + def set_header(self, name, value): """Set a header for this response to a given value. Warning: Calling this method overwrites the existing value, if any. + Warning: + For setting cookies, see instead :meth:`~.set_cookie` + Args: name (str): Header name to set (case-insensitive). Must be of type ``str`` or ``StringType``, and only character values 0x00 @@ -164,6 +281,9 @@ class Response(object): to it, delimited by a comma. Most header specifications support this format, Cookie and Set-Cookie being the notable exceptions. + Warning: + For setting cookies, see :py:meth:`~.set_cookie` + Args: name (str): Header name to set (case-insensitive). Must be of type ``str`` or ``StringType``, and only character values 0x00 @@ -431,6 +551,19 @@ class Response(object): if six.PY2: # pragma: no cover # PERF(kgriffs): Don't create an extra list object if # it isn't needed. - return headers.items() + items = headers.items() + else: + items = list(headers.items()) # pragma: no cover - return list(headers.items()) # pragma: no cover + if self._cookies is not None: + # PERF(tbug): + # The below implementation is ~23% faster than + # the alternative: + # + # self._cookies.output().split("\\r\\n") + # + # Even without the .split("\\r\\n"), the below + # is still ~17% faster, so don't use .output() + items += [("set-cookie", c.OutputString()) + for c in self._cookies.values()] + return items diff --git a/falcon/response_helpers.py b/falcon/response_helpers.py index 6f2c311..fd09504 100644 --- a/falcon/response_helpers.py +++ b/falcon/response_helpers.py @@ -60,3 +60,21 @@ def format_range(value): str(value[0]) + '-' + str(value[1]) + '/' + str(value[2])) + + +def is_ascii_encodable(s): # pragma: no cover + """Check if argument encodes to ascii without error.""" + try: + s.encode("ascii") + except UnicodeEncodeError: + # NOTE(tbug): Py2 and Py3 will raise this if string contained + # chars that could not be ascii encoded + return False + except UnicodeDecodeError: + # NOTE(tbug): py2 will raise this if type is str + # and contains non-ascii chars + return False + except AttributeError: + # NOTE(tbug): s is probably not a string type + return False + return True diff --git a/falcon/util/__init__.py b/falcon/util/__init__.py index 6a9233c..219db47 100644 --- a/falcon/util/__init__.py +++ b/falcon/util/__init__.py @@ -1,5 +1,6 @@ # Hoist misc. utils from falcon.util.misc import * # NOQA +from falcon.util.time import * from falcon.util import structures CaseInsensitiveDict = structures.CaseInsensitiveDict diff --git a/falcon/util/time.py b/falcon/util/time.py new file mode 100644 index 0000000..67c4f38 --- /dev/null +++ b/falcon/util/time.py @@ -0,0 +1,16 @@ +import datetime + + +class TimezoneGMT(datetime.tzinfo): + """Used in cookie response formatting""" + + GMT_ZERO = datetime.timedelta(hours=0) + + def utcoffset(self, dt): + return self.GMT_ZERO + + def tzname(self, dt): + return "GMT" + + def dst(self, dt): + return self.GMT_ZERO diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 0000000..e98d666 --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,169 @@ + +import falcon +import falcon.testing as testing + +from falcon.util import TimezoneGMT +from datetime import datetime, timedelta, tzinfo + +from six.moves.http_cookies import Morsel + + +class TimezoneGMTPlus1(tzinfo): + + def utcoffset(self, dt): + return timedelta(hours=1) + + def tzname(self, dt): + return "GMT+1" + + def dst(self, dt): + return timedelta(hours=1) + +GMT_PLUS_ONE = TimezoneGMTPlus1() + + +class CookieResource: + + def on_get(self, req, resp): + resp.set_cookie("foo", "bar", domain="example.com", path="/") + + def on_head(self, req, resp): + resp.set_cookie("foo", "bar", max_age=300) + resp.set_cookie("bar", "baz", http_only=False) + resp.set_cookie("bad", "cookie") + resp.unset_cookie("bad") + + def on_post(self, req, resp): + e = datetime(year=2050, month=1, day=1) # naive + resp.set_cookie("foo", "bar", http_only=False, secure=False, expires=e) + resp.unset_cookie("bad") + + def on_put(self, req, resp): + e = datetime(year=2050, month=1, day=1, tzinfo=GMT_PLUS_ONE) # aware + resp.set_cookie("foo", "bar", http_only=False, secure=False, expires=e) + resp.unset_cookie("bad") + + +class TestCookies(testing.TestBase): + + # + # Response + # + + def test_response_base_case(self): + self.resource = CookieResource() + self.api.add_route(self.test_route, self.resource) + self.simulate_request(self.test_route, method="GET") + self.assertIn( + ("set-cookie", + "foo=bar; Domain=example.com; httponly; Path=/; secure"), + self.srmock.headers) + + def test_response_complex_case(self): + self.resource = CookieResource() + self.api.add_route(self.test_route, self.resource) + self.simulate_request(self.test_route, method="HEAD") + self.assertIn(("set-cookie", "foo=bar; httponly; Max-Age=300; secure"), + self.srmock.headers) + self.assertIn(("set-cookie", "bar=baz; secure"), self.srmock.headers) + self.assertNotIn(("set-cookie", "bad=cookie"), self.srmock.headers) + + def test_cookie_expires_naive(self): + self.resource = CookieResource() + self.api.add_route(self.test_route, self.resource) + self.simulate_request(self.test_route, method="POST") + self.assertIn( + ("set-cookie", "foo=bar; expires=Sat, 01 Jan 2050 00:00:00 GMT"), + self.srmock.headers) + + def test_cookie_expires_aware(self): + self.resource = CookieResource() + self.api.add_route(self.test_route, self.resource) + self.simulate_request(self.test_route, method="PUT") + self.assertIn( + ("set-cookie", "foo=bar; expires=Fri, 31 Dec 2049 23:00:00 GMT"), + self.srmock.headers) + + def test_cookies_setable(self): + resp = falcon.Response() + + self.assertIsNone(resp._cookies) + + resp.set_cookie("foo", "wrong-cookie", max_age=301) + resp.set_cookie("foo", "bar", max_age=300) + morsel = resp._cookies["foo"] + + self.assertIsInstance(morsel, Morsel) + self.assertEqual(morsel.key, "foo") + self.assertEqual(morsel.value, "bar") + self.assertEqual(morsel["max-age"], 300) + + def test_response_unset_cookie(self): + resp = falcon.Response() + resp.unset_cookie("bad") + resp.set_cookie("bad", "cookie", max_age=301) + resp.unset_cookie("bad") + + morsels = list(resp._cookies.values()) + + self.assertEqual(len(morsels), 0) + + def test_cookie_timezone(self): + tz = TimezoneGMT() + self.assertEqual("GMT", tz.tzname(timedelta(0))) + + # + # Request + # + + def test_request_cookie_parsing(self): + # testing with a github-ish set of cookies + headers = [ + ('Cookie', '''Cookie: + logged_in=no;_gh_sess=eyJzZXXzaW9uX2lkIjoiN2; + tz=Europe/Berlin; _ga=GA1.2.332347814.1422308165; + _gat=1; + _octo=GH1.1.201722077.1422308165'''), + ] + + environ = testing.create_environ(headers=headers) + req = falcon.Request(environ) + + self.assertEqual("no", req.cookies["logged_in"]) + self.assertEqual("Europe/Berlin", req.cookies["tz"]) + self.assertEqual("GH1.1.201722077.1422308165", req.cookies["_octo"]) + + self.assertIn("logged_in", req.cookies) + self.assertIn("_gh_sess", req.cookies) + self.assertIn("tz", req.cookies) + self.assertIn("_ga", req.cookies) + self.assertIn("_gat", req.cookies) + self.assertIn("_octo", req.cookies) + + def test_unicode_inside_ascii_range(self): + resp = falcon.Response() + # should be ok + resp.set_cookie("non_unicode_ascii_name_1", "ascii_value") + resp.set_cookie(u"unicode_ascii_name_1", "ascii_value") + resp.set_cookie("non_unicode_ascii_name_2", u"unicode_ascii_value") + resp.set_cookie(u"unicode_ascii_name_2", u"unicode_ascii_value") + + def test_unicode_outside_ascii_range(self): + def set_bad_cookie_name(): + resp = falcon.Response() + resp.set_cookie(u"unicode_\xc3\xa6\xc3\xb8", "ok_value") + self.assertRaises(KeyError, set_bad_cookie_name) + + def set_bad_cookie_value(): + resp = falcon.Response() + resp.set_cookie("ok_name", u"unicode_\xc3\xa6\xc3\xb8") + # NOTE(tbug): we need to grab the exception to check + # that it is not instance of UnicodeEncodeError, so + # we cannot simply use assertRaises + try: + set_bad_cookie_value() + except ValueError as e: + self.assertIsInstance(e, ValueError) + self.assertNotIsInstance(e, UnicodeEncodeError) + else: + self.fail("set_bad_cookie_value did not fail as expected")