# -*- coding: utf-8 -*- r""" werkzeug.contrib.securecookie ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This module implements a cookie that is not alterable from the client because it adds a checksum the server checks for. You can use it as session replacement if all you have is a user id or something to mark a logged in user. Keep in mind that the data is still readable from the client as a normal cookie is. However you don't have to store and flush the sessions you have at the server. Example usage: >>> from werkzeug.contrib.securecookie import SecureCookie >>> x = SecureCookie({"foo": 42, "baz": (1, 2, 3)}, "deadbeef") Dumping into a string so that one can store it in a cookie: >>> value = x.serialize() Loading from that string again: >>> x = SecureCookie.unserialize(value, "deadbeef") >>> x["baz"] (1, 2, 3) If someone modifies the cookie and the checksum is wrong the unserialize method will fail silently and return a new empty `SecureCookie` object. Keep in mind that the values will be visible in the cookie so do not store data in a cookie you don't want the user to see. Application Integration ======================= If you are using the werkzeug request objects you could integrate the secure cookie into your application like this:: from werkzeug.utils import cached_property from werkzeug.wrappers import BaseRequest from werkzeug.contrib.securecookie import SecureCookie # don't use this key but a different one; you could just use # os.urandom(20) to get something random SECRET_KEY = '\xfa\xdd\xb8z\xae\xe0}4\x8b\xea' class Request(BaseRequest): @cached_property def client_session(self): data = self.cookies.get('session_data') if not data: return SecureCookie(secret_key=SECRET_KEY) return SecureCookie.unserialize(data, SECRET_KEY) def application(environ, start_response): request = Request(environ, start_response) # get a response object here response = ... if request.client_session.should_save: session_data = request.client_session.serialize() response.set_cookie('session_data', session_data, httponly=True) return response(environ, start_response) A less verbose integration can be achieved by using shorthand methods:: class Request(BaseRequest): @cached_property def client_session(self): return SecureCookie.load_cookie(self, secret_key=COOKIE_SECRET) def application(environ, start_response): request = Request(environ, start_response) # get a response object here response = ... request.client_session.save_cookie(response) return response(environ, start_response) :copyright: (c) 2014 by the Werkzeug Team, see AUTHORS for more details. :license: BSD, see LICENSE for more details. """ import pickle import base64 from hmac import new as hmac from time import time from hashlib import sha1 as _default_hash from werkzeug._compat import iteritems, text_type from werkzeug.urls import url_quote_plus, url_unquote_plus from werkzeug._internal import _date_to_unix from werkzeug.contrib.sessions import ModificationTrackingDict from werkzeug.security import safe_str_cmp from werkzeug._compat import to_native class UnquoteError(Exception): """Internal exception used to signal failures on quoting.""" class SecureCookie(ModificationTrackingDict): """Represents a secure cookie. You can subclass this class and provide an alternative mac method. The import thing is that the mac method is a function with a similar interface to the hashlib. Required methods are update() and digest(). Example usage: >>> x = SecureCookie({"foo": 42, "baz": (1, 2, 3)}, "deadbeef") >>> x["foo"] 42 >>> x["baz"] (1, 2, 3) >>> x["blafasel"] = 23 >>> x.should_save True :param data: the initial data. Either a dict, list of tuples or `None`. :param secret_key: the secret key. If not set `None` or not specified it has to be set before :meth:`serialize` is called. :param new: The initial value of the `new` flag. """ #: The hash method to use. This has to be a module with a new function #: or a function that creates a hashlib object. Such as `hashlib.md5` #: Subclasses can override this attribute. The default hash is sha1. #: Make sure to wrap this in staticmethod() if you store an arbitrary #: function there such as hashlib.sha1 which might be implemented #: as a function. hash_method = staticmethod(_default_hash) #: the module used for serialization. Unless overriden by subclasses #: the standard pickle module is used. serialization_method = pickle #: if the contents should be base64 quoted. This can be disabled if the #: serialization process returns cookie safe strings only. quote_base64 = True def __init__(self, data=None, secret_key=None, new=True): ModificationTrackingDict.__init__(self, data or ()) # explicitly convert it into a bytestring because python 2.6 # no longer performs an implicit string conversion on hmac if secret_key is not None: secret_key = bytes(secret_key) self.secret_key = secret_key self.new = new def __repr__(self): return '<%s %s%s>' % ( self.__class__.__name__, dict.__repr__(self), self.should_save and '*' or '' ) @property def should_save(self): """True if the session should be saved. By default this is only true for :attr:`modified` cookies, not :attr:`new`. """ return self.modified @classmethod def quote(cls, value): """Quote the value for the cookie. This can be any object supported by :attr:`serialization_method`. :param value: the value to quote. """ if cls.serialization_method is not None: value = cls.serialization_method.dumps(value) if cls.quote_base64: value = b''.join(base64.b64encode(value).splitlines()).strip() return value @classmethod def unquote(cls, value): """Unquote the value for the cookie. If unquoting does not work a :exc:`UnquoteError` is raised. :param value: the value to unquote. """ try: if cls.quote_base64: value = base64.b64decode(value) if cls.serialization_method is not None: value = cls.serialization_method.loads(value) return value except Exception: # unfortunately pickle and other serialization modules can # cause pretty every error here. if we get one we catch it # and convert it into an UnquoteError raise UnquoteError() def serialize(self, expires=None): """Serialize the secure cookie into a string. If expires is provided, the session will be automatically invalidated after expiration when you unseralize it. This provides better protection against session cookie theft. :param expires: an optional expiration date for the cookie (a :class:`datetime.datetime` object) """ if self.secret_key is None: raise RuntimeError('no secret key defined') if expires: self['_expires'] = _date_to_unix(expires) result = [] mac = hmac(self.secret_key, None, self.hash_method) for key, value in sorted(self.items()): result.append(('%s=%s' % ( url_quote_plus(key), self.quote(value).decode('ascii') )).encode('ascii')) mac.update(b'|' + result[-1]) return b'?'.join([ base64.b64encode(mac.digest()).strip(), b'&'.join(result) ]) @classmethod def unserialize(cls, string, secret_key): """Load the secure cookie from a serialized string. :param string: the cookie value to unserialize. :param secret_key: the secret key used to serialize the cookie. :return: a new :class:`SecureCookie`. """ if isinstance(string, text_type): string = string.encode('utf-8', 'replace') if isinstance(secret_key, text_type): secret_key = secret_key.encode('utf-8', 'replace') try: base64_hash, data = string.split(b'?', 1) except (ValueError, IndexError): items = () else: items = {} mac = hmac(secret_key, None, cls.hash_method) for item in data.split(b'&'): mac.update(b'|' + item) if b'=' not in item: items = None break key, value = item.split(b'=', 1) # try to make the key a string key = url_unquote_plus(key.decode('ascii')) try: key = to_native(key) except UnicodeError: pass items[key] = value # no parsing error and the mac looks okay, we can now # sercurely unpickle our cookie. try: client_hash = base64.b64decode(base64_hash) except TypeError: items = client_hash = None if items is not None and safe_str_cmp(client_hash, mac.digest()): try: for key, value in iteritems(items): items[key] = cls.unquote(value) except UnquoteError: items = () else: if '_expires' in items: if time() > items['_expires']: items = () else: del items['_expires'] else: items = () return cls(items, secret_key, False) @classmethod def load_cookie(cls, request, key='session', secret_key=None): """Loads a :class:`SecureCookie` from a cookie in request. If the cookie is not set, a new :class:`SecureCookie` instanced is returned. :param request: a request object that has a `cookies` attribute which is a dict of all cookie values. :param key: the name of the cookie. :param secret_key: the secret key used to unquote the cookie. Always provide the value even though it has no default! """ data = request.cookies.get(key) if not data: return cls(secret_key=secret_key) return cls.unserialize(data, secret_key) def save_cookie(self, response, key='session', expires=None, session_expires=None, max_age=None, path='/', domain=None, secure=None, httponly=False, force=False): """Saves the SecureCookie in a cookie on response object. All parameters that are not described here are forwarded directly to :meth:`~BaseResponse.set_cookie`. :param response: a response object that has a :meth:`~BaseResponse.set_cookie` method. :param key: the name of the cookie. :param session_expires: the expiration date of the secure cookie stored information. If this is not provided the cookie `expires` date is used instead. """ if force or self.should_save: data = self.serialize(session_expires or expires) response.set_cookie(key, data, expires=expires, max_age=max_age, path=path, domain=domain, secure=secure, httponly=httponly)