diff options
| author | Ben Bangert <ben@groovie.org> | 2010-10-28 19:58:38 -0700 |
|---|---|---|
| committer | Ben Bangert <ben@groovie.org> | 2010-10-28 19:58:38 -0700 |
| commit | d39e99e1f6f06a02a275a8a30f154e0f292d7dff (patch) | |
| tree | 24a85615dce8ba575c73aae1189ea5846303ae70 | |
| parent | 6a9d6246c9e9fda507fa04b462c00509b73f676f (diff) | |
| parent | 04ebd572a92f6681209c70c42192775c63cd16cd (diff) | |
| download | pyramid-d39e99e1f6f06a02a275a8a30f154e0f292d7dff.tar.gz pyramid-d39e99e1f6f06a02a275a8a30f154e0f292d7dff.tar.bz2 pyramid-d39e99e1f6f06a02a275a8a30f154e0f292d7dff.zip | |
Merge branch 'master' of github.com:Pylons/pyramid
| -rw-r--r-- | CHANGES.txt | 7 | ||||
| -rw-r--r-- | docs/api.rst | 1 | ||||
| -rw-r--r-- | docs/api/configuration.rst | 4 | ||||
| -rw-r--r-- | docs/api/interfaces.rst | 4 | ||||
| -rw-r--r-- | docs/api/request.rst | 8 | ||||
| -rw-r--r-- | docs/api/session.rst | 9 | ||||
| -rw-r--r-- | docs/glossary.rst | 11 | ||||
| -rw-r--r-- | docs/index.rst | 1 | ||||
| -rw-r--r-- | docs/narr/security.rst | 2 | ||||
| -rw-r--r-- | docs/narr/sessions.rst | 152 | ||||
| -rw-r--r-- | docs/narr/webob.rst | 12 | ||||
| -rw-r--r-- | pyramid/configuration.py | 31 | ||||
| -rw-r--r-- | pyramid/interfaces.py | 117 | ||||
| -rw-r--r-- | pyramid/request.py | 17 | ||||
| -rw-r--r-- | pyramid/session.py | 240 | ||||
| -rw-r--r-- | pyramid/tests/test_configuration.py | 12 | ||||
| -rw-r--r-- | pyramid/tests/test_request.py | 24 | ||||
| -rw-r--r-- | pyramid/tests/test_session.py | 252 |
18 files changed, 893 insertions, 11 deletions
diff --git a/CHANGES.txt b/CHANGES.txt index 905bd27f1..1e4d73c6c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -24,6 +24,13 @@ Features (delta from BFG 1.3.X) a Pylons-style "view handler" (such a thing used to be called a "controller" in Pylons 1.0). +- New argument to configurator: ``session_factory``. + +- New method on configurator: ``set_session_factory`` + +- Using ``request.session`` now returns a (dictionary-like) session + object if a session factory has been configured. + Documentation (delta from BFG 1.3) ----------------------------------- diff --git a/docs/api.rst b/docs/api.rst index 8d93ff450..8e7c0c283 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -25,6 +25,7 @@ documentation is organized alphabetically by module name. api/router api/scripting api/security + api/session api/settings api/testing api/threadlocal diff --git a/docs/api/configuration.rst b/docs/api/configuration.rst index 5215bfb3c..6d5c9f16b 100644 --- a/docs/api/configuration.rst +++ b/docs/api/configuration.rst @@ -5,7 +5,7 @@ .. automodule:: pyramid.configuration - .. autoclass:: Configurator(registry=None, package=None, settings=None, root_factory=None, authentication_policy=None, authorization_policy=None, renderers=DEFAULT_RENDERERS, debug_logger=None, locale_negotiator=None, request_factory=None, renderer_globals_factory=None, default_permission=None) + .. autoclass:: Configurator(registry=None, package=None, settings=None, root_factory=None, authentication_policy=None, authorization_policy=None, renderers=DEFAULT_RENDERERS, debug_logger=None, locale_negotiator=None, request_factory=None, renderer_globals_factory=None, default_permission=None, session_factory=None) .. attribute:: registry @@ -64,6 +64,8 @@ .. automethod:: set_default_permission + .. automethod:: set_session_factory + .. automethod:: set_request_factory .. automethod:: set_renderer_globals_factory diff --git a/docs/api/interfaces.rst b/docs/api/interfaces.rst index 7193fd11b..2bf55474e 100644 --- a/docs/api/interfaces.rst +++ b/docs/api/interfaces.rst @@ -25,3 +25,7 @@ Other Interfaces .. autointerface:: IRoutePregenerator + .. autointerface:: ISession + + .. autointerface:: ISessionFactory + diff --git a/docs/api/request.rst b/docs/api/request.rst index e53028b0f..9e851ba8d 100644 --- a/docs/api/request.rst +++ b/docs/api/request.rst @@ -85,3 +85,11 @@ of ``request.exception`` will be ``None`` within response and finished callbacks. + .. attribute:: session + + If a :term:`session factory` has been configured, this attribute + will represent the current user's :term:`session` object. If a + session factory *has not* been configured, requesting the + ``request.session`` attribute will cause a + :class:`pyramid.exceptions.ConfigurationError` to be raised. + diff --git a/docs/api/session.rst b/docs/api/session.rst new file mode 100644 index 000000000..daed9fc33 --- /dev/null +++ b/docs/api/session.rst @@ -0,0 +1,9 @@ +.. _session_module: + +:mod:`pyramid.session` +--------------------------- + +.. automodule:: pyramid.session + + .. autofunction:: InsecureCookieSessionFactoryConfig + diff --git a/docs/glossary.rst b/docs/glossary.rst index 2e2b11aaa..93d86b664 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -803,3 +803,14 @@ Glossary ``route_url``. See :class:`repoze.bfg.interfaces.IRoutePregenerator` for more information. + + session + A namespace that is valid for some period of continual activity + that can be used to represent a user's interaction with a web + application. + + session factory + A callable, which, when called with a single argument named + ``request`` (a :term:`request` object), returns a + :term:`session` object. + diff --git a/docs/index.rst b/docs/index.rst index 3b62b5fac..c774c11d1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ Narrative documentation in chapter form explaining how to use narr/views narr/static narr/webob + narr/sessions narr/templates narr/models narr/security diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 109009842..5782837aa 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -241,7 +241,7 @@ application: When a default permission is registered: -- if a view configuration names an explicit ``permission`, the default +- if a view configuration names an explicit ``permission``, the default permission is ignored for that view registration, and the view-configuration-named permission is used. diff --git a/docs/narr/sessions.rst b/docs/narr/sessions.rst new file mode 100644 index 000000000..e460e2d74 --- /dev/null +++ b/docs/narr/sessions.rst @@ -0,0 +1,152 @@ +.. index:: + single: session + +.. _sessions_chapter: + +Session Objects +=============== + +A :term:`session` is a namespace which is valid for some period of +continual activity that can be used to represent a user's interaction +with a web application. + +Using The Default Session Factory +--------------------------------- + +In order to use sessions, you must set up a :term:`session factory` +during your :mod:`pyramid` configuration. + +A very basic, insecure sample session factory implementation is +provided in the :mod:`pyramid` core. It uses a cookie to store +session information. This implementation has the following +limitation: + +- The session information in the cookies used by this implementation + is *not* encrypted, so it can be viewed by anyone with access to the + cookie storage of the user's browser or anyone with access to the + network along which the cookie travels. + +- The maximum number of bytes that are storable in a serialized + representation of the session is fewer than 4000. Only very small + data sets can be kept in this + +It is, however, digitally signed, and thus its data cannot easily be +tampered with. + +You can configure this session factory in your :mod:`pyramid` +application by using the ``session_factory`` argument to the +:class:`pyramid.configuration.Configurator` class: + +.. code-block:: python + :linenos: + + from pyramid.session import InsecureCookieSessionFactoryConfig + my_session_factory = InsecureCookieSessionFactoryConfig('itsaseekreet') + + from pyramid.configuration import Configurator + config = Configurator(session_factory = my_session_factory) + +.. warning:: + + Note the very long, very explicit name for + ``InsecureCookieSessionFactoryConfig``. It's trying to tell you + that this implementation is, by default, *insecure*. You should + not use it when you keep sensitive information in the session + object, as the information can be easily read by both users of your + application and third parties who have access to your users' + network traffic. Use a different session factory implementation + (preferably one which keeps session data on the server) for + anything but the most basic of applications where "session security + doesn't matter". + +Using a Session Object +---------------------- + +Once a session factory has been configured for your application, you +can access session objects provided by the session factory by asking +for the ``session`` attribute of any :term:`request` object. For +example: + +.. code-block:: python + :linenos: + + from webob import Response + + def myview(request): + session = request.session + if 'abc' in session: + session['fred'] = 'yes' + session['abc'] = '123' + if 'fred' in session: + return Response('Fred was in the session') + else: + return Response('Fred was not in the session') + +You can use a session much like a Python dictionary. It supports all +methods of a Python dictionary, and it has three extra attributes, and +two extra methods. + +Extra attributes: + +``modified`` + An integer timestamp indicating the last time the session was modified. + +``created`` + An integer timestamp indicating the time that this session was created. + +``new`` + A boolean. If ``new`` is True, this session is new. Otherwise, it has + been constituted from data that was already serialized. + +Extra methods: + +``changed()`` + Call this when you mutate a mutable value in the session namespace. + +``invalidate()`` + Call this when you want to invalidate the session (dump all data, + and -- perhaps -- set a clearing cookie). + +The formal definition of the methods and attributes supported by the +session object are in the :class:`pyramid.interfaces.ISession` +documentation. + +Some gotchas: + +- Keys and values of session data must be *pickleable*. This means, + typically, that they must be instances of basic types of objects, + such as strings, lists, dictionaries, tuples, integers, etc. If you + place an object in a session data key or value that is not + pickleable, an error will be raised when the session is serialized. + +- If you place a mutable value (for example, a list or a dictionary) + in a session object, and you subsequently mutate that value, you + must call the ``changed()`` method of the session object. This is + because, although the session object can detect when you call its + data-modifying methods such as ``__setitem__``, ``pop`` and other + (and thus the session knows it needs to reserialize the session + data), when you change a mutable object stored in the session + itself, the session has no way to know that you changed that value. + When in doubt, call ``changed()`` after you've changed sessioning + data. + +Using Alternate Session Factories +--------------------------------- + +At the time of this writing, alternate session factories don't yet +exist. It is our intent that we will soon provide at least one other +session factory which will be easily installable: one that uses the +`Beaker <http://beaker.groovie.org/>`_ library as a backend. + +Creating Your Own Session Factory +--------------------------------- + +If none of the default or otherwise available sessioning +implementations for :mod:`pyramid` suit you, you may create your own +session object by implementing a :term:`session factory`. Your +session factory should return a :term:`session`. The interfaces for +both types are available in +:class:`pyramid.interfaces.ISessionFactory` and +:class:`pyramid.interfaces.ISession`. You might use the cookie +implementation in the :mod:`pyramid.session` module as inspiration. + diff --git a/docs/narr/webob.rst b/docs/narr/webob.rst index b3bec882e..15f8da9cf 100644 --- a/docs/narr/webob.rst +++ b/docs/narr/webob.rst @@ -109,12 +109,12 @@ instance, ``req.if_modified_since`` returns a `datetime Special Attributes Added to the Request by :mod:`pyramid` ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -In addition to the standard :term:`WebOb` attributes, -:mod:`pyramid` adds special attributes to every request: -``context``, ``registry``, ``root``, ``subpath``, ``traversed``, -``view_name``, ``virtual_root`` , and ``virtual_root_path``. These -attributes are documented further within the -:class:`pyramid.request.Request` API documentation. +In addition to the standard :term:`WebOb` attributes, :mod:`pyramid` +adds special attributes to every request: ``context``, ``registry``, +``root``, ``subpath``, ``traversed``, ``view_name``, ``virtual_root`` +, ``virtual_root_path``, and ``session``. These attributes are +documented further within the :class:`pyramid.request.Request` API +documentation. .. index:: single: request URLs diff --git a/pyramid/configuration.py b/pyramid/configuration.py index b3ba7a20b..f159a342f 100644 --- a/pyramid/configuration.py +++ b/pyramid/configuration.py @@ -42,6 +42,7 @@ from pyramid.interfaces import IView from pyramid.interfaces import IViewClassifier from pyramid.interfaces import IExceptionResponse from pyramid.interfaces import IException +from pyramid.interfaces import ISessionFactory from pyramid import chameleon_text from pyramid import chameleon_zpt @@ -187,7 +188,17 @@ class Configurator(object): always be executable by entirely anonymous users (any authorization policy in effect is ignored). See also :ref:`setting_a_default_permission`. - """ + + If ``session_factory`` is passed, it should be an object which + implements the :term:`session factory` interface. If a nondefault + value is passed, the ``session_factory`` will be used to create a + session object when ``request.session`` is accessed. Note that + the same outcome can be achieved by calling + :ref:`pyramid.configration.Configurator.set_session_factory`. By + default, this argument is ``None``, indicating that no session + factory will be configured (and thus accessing ``request.session`` + will throw an error) unless ``set_session_factory`` is called later + during configuration. """ manager = manager # for testing injection venusian = venusian # for testing injection @@ -205,7 +216,9 @@ class Configurator(object): locale_negotiator=None, request_factory=None, renderer_globals_factory=None, - default_permission=None): + default_permission=None, + session_factory=None, + ): if package is None: package = caller_package() name_resolver = DottedNameResolver(package) @@ -227,6 +240,7 @@ class Configurator(object): request_factory=request_factory, renderer_globals_factory=renderer_globals_factory, default_permission=default_permission, + session_factory=session_factory, ) def _set_settings(self, mapping): @@ -362,7 +376,8 @@ class Configurator(object): renderers=DEFAULT_RENDERERS, debug_logger=None, locale_negotiator=None, request_factory=None, renderer_globals_factory=None, - default_permission=None): + default_permission=None, + session_factory=None): """ When you pass a non-``None`` ``registry`` argument to the :term:`Configurator` constructor, no initial 'setup' is performed against the registry. This is because the registry @@ -408,6 +423,8 @@ class Configurator(object): self.set_renderer_globals_factory(renderer_globals_factory) if default_permission: self.set_default_permission(default_permission) + if session_factory is not None: + self.set_session_factory(session_factory) # getSiteManager is a unit testing dep injection def hook_zca(self, getSiteManager=None): @@ -1800,6 +1817,14 @@ class Configurator(object): """ self.registry.registerUtility(permission, IDefaultPermission) + def set_session_factory(self, session_factory): + """ + Configure the application with a :term:`session factory`. If + this method is called, the ``session_factory`` argument must + be a session factory callable. + """ + self.registry.registerUtility(session_factory, ISessionFactory) + def add_translation_dirs(self, *specs): """ Add one or more :term:`translation directory` paths to the current configuration state. The ``specs`` argument is a diff --git a/pyramid/interfaces.py b/pyramid/interfaces.py index 9b7488f1e..e1d7491b5 100644 --- a/pyramid/interfaces.py +++ b/pyramid/interfaces.py @@ -375,5 +375,122 @@ class IDefaultPermission(Interface): """ A string object representing the default permission to be used for all view configurations which do not explicitly declare their own.""" + +class ISessionFactory(Interface): + """ An interface representing a factory which accepts a request object and + returns an ISession object """ + def __call__(request): + """ Return an ISession object """ + +class ISession(Interface): + """ An interface representing a session (a web session object, + usually accessed via ``request.session``. + + Keys and values of a session must be pickleable. + """ + + # attributes + + created = Attribute('Integer representing Epoch time when created.') + modified = Attribute( + 'Integer representing Epoch time of last modification. If the ' + 'session has not yet been modified (it is new), this time will ' + 'be the created time.') + new = Attribute('Boolean attribute. If ``True``, the session is new.') + + # special methods + + def invalidate(): + """ Invalidate the session. The action caused by + ``invalidate`` is implementation-dependent, but it should have + the effect of completely dissociating any data stored in the + session with the current request. It might set response + values (such as one which clears a cookie), or it might not.""" + + def changed(): + """ Mark the session as changed. A user of a session should + call this method after he or she mutates a mutable object that + is *a value of the session* (it should not be required after + mutating the session itself). For example, if the user has + stored a dictionary in the session under the key ``foo``, and + he or she does ``session['foo'] = {}``, ``changed()`` needn't + be called. However, if subsequently he or she does + ``session['foo']['a'] = 1``, ``changed()`` must be called for + the sessioning machinery to notice the mutation of the + internal dictionary.""" + + # mapping methods + def __getitem__(key): + """Get a value for a key + + A ``KeyError`` is raised if there is no value for the key. + """ + + def get(key, default=None): + """Get a value for a key + + The default is returned if there is no value for the key. + """ + + def __delitem__(key): + """Delete a value from the mapping using the key. + + A ``KeyError`` is raised if there is no value for the key. + """ + + def __setitem__(key, value): + """Set a new item in the mapping.""" + + def keys(): + """Return the keys of the mapping object. + """ + + def values(): + """Return the values of the mapping object. + """ + + def items(): + """Return the items of the mapping object. + """ + + def iterkeys(): + "iterate over keys; equivalent to __iter__" + + def itervalues(): + "iterate over values" + + def iteritems(): + "iterate over items" + + def clear(): + "delete all items" + + def update(d): + " Update D from E: for k in E.keys(): D[k] = E[k]" + + def setdefault(key, default=None): + " D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D " + + def pop(k, *args): + """remove specified key and return the corresponding value + ``*args`` may contain a single default value, or may not be supplied. + If key is not found, default is returned if given, otherwise + ``KeyError`` is raised""" + + def popitem(): + """remove and return some (key, value) pair as a + 2-tuple; but raise ``KeyError`` if mapping is empty""" + + def __len__(): + """Return the number of items in the session. + """ + + def __iter__(): + """Return an iterator for the keys of the mapping object. + """ + + def __contains__(key): + """Return true if a key exists in the mapping.""" + NO_PERMISSION_REQUIRED = '__no_permission_required__' diff --git a/pyramid/request.py b/pyramid/request.py index 19d4a260c..7ffdb7495 100644 --- a/pyramid/request.py +++ b/pyramid/request.py @@ -4,6 +4,10 @@ from zope.interface.interface import InterfaceClass from webob import Request as WebobRequest from pyramid.interfaces import IRequest +from pyramid.interfaces import ISessionFactory + +from pyramid.exceptions import ConfigurationError +from pyramid.decorator import reify class Request(WebobRequest): """ @@ -137,6 +141,19 @@ class Request(WebobRequest): callback = callbacks.pop(0) callback(self) + @reify + def session(self): + """ Obtain the :term:`session` object associated with this + request. If a :term:`session factory` has not been registered + during application configuration, a + :class:`pyramid.exceptions.ConfigurationError` will be raised""" + factory = self.registry.queryUtility(ISessionFactory) + if factory is None: + raise ConfigurationError( + 'No session factory registered ' + '(see the Session Objects chapter of the documentation)') + return factory(self) + # override default WebOb "environ['adhoc_attr']" mutation behavior __getattr__ = object.__getattribute__ __setattr__ = object.__setattr__ diff --git a/pyramid/session.py b/pyramid/session.py new file mode 100644 index 000000000..50f071398 --- /dev/null +++ b/pyramid/session.py @@ -0,0 +1,240 @@ +try: + from hashlib import sha1 +except ImportError: # pragma: no cover + import sha as sha1 + +try: + import cPickle as pickle +except ImportError: # pragma: no cover + import pickle + +from webob import Response + +import hmac +import binascii +import time +import base64 + +def manage_accessed(wrapped): + """ Decorator which causes a cookie to be set when a wrapped + method is called""" + def accessed(session, *arg, **kw): + session.accessed = int(time.time()) + if not session._dirty: + session._dirty = True + def set_cookie_callback(request, response): + session._set_cookie(response) + session.request = None # explicitly break cycle for gc + session.request.add_response_callback(set_cookie_callback) + return wrapped(session, *arg, **kw) + accessed.__doc__ = wrapped.__doc__ + return accessed + +def manage_modified(wrapped): + accessed = manage_accessed(wrapped) + def modified(session, *arg, **kw): + session.modified = int(time.time()) + return accessed(session, *arg, **kw) + modified.__doc__ = accessed.__doc__ + return modified + +def InsecureCookieSessionFactoryConfig( + secret, + timeout=1200, + cookie_name='session', + cookie_max_age=None, + cookie_path='/', + cookie_domain=None, + cookie_secure=False, + cookie_httponly=False, + cookie_on_exception=False, + ): + """ + Configure a :term:`session factory` which will provide insecure + (but signed) cookie-based sessions. The return value of this + function is a :term:`session factory`, which may be provided as + the ``session_factory`` argument of a + :class:`pyramid.configuration.Configurator` constructor, or used + as the ``session_factory`` argument of the + :meth:`pyramid.configuration.Configurator.set_session_factory` + method. + + The session factory returned by this function will create sessions + which are limited to storing fewer than 4000 bytes of data (as the + payload must fit into a single cookie). + + Parameters: + + ``secret`` + A string which is used to sign the cookie. + + ``timeout`` + A number of seconds of inactivity before a session times out. + + ``cookie_name`` + The name of the cookie used for sessioning. Default: ``session``. + + ``cookie_max_age`` + The maximum age of the cookie used for sessioning (in seconds). + Default: ``None`` (browser scope). + + ``cookie_path`` + The path used for the session cookie. Default: ``/``. + + ``cookie_domain`` + The domain used for the session cookie. Default: ``None`` (no domain). + + ``cookie_secure`` + The 'secure' flag of the session cookie. Default: ``False``. + + ``cookie_httponly`` + The 'httpOnly' flag of the session cookie. Default: ``False``. + + ``cookie_on_exception`` + If ``True``, set a session cookie even if an exception occurs + while rendering a view. Default: ``False``. + + """ + + class InsecureCookieSessionFactory(dict): + """ Dictionary-like session object """ + + # configuration parameters + _cookie_name = cookie_name + _cookie_max_age = cookie_max_age + _cookie_path = cookie_path + _cookie_domain = cookie_domain + _cookie_secure = cookie_secure + _cookie_httponly = cookie_httponly + _cookie_on_exception = cookie_on_exception + _secret = secret + _timeout = timeout + + # dirty flag + _dirty = False + + def __init__(self, request): + self.request = request + now = time.time() + created = accessed = modified = now + new = True + cookieval = request.cookies.get(self._cookie_name) + value = deserialize(cookieval, self._secret) + state = {} + if value is not None: + accessed, created, modified, state = value + new = False + if now - accessed > self._timeout: + state = {} + + self.created = created + self.accessed = accessed + self.modified = modified + self.new = new + dict.__init__(self, state) + + # ISession methods + def changed(self): + """ This is intentionally a noop; the session is + serialized on every access, so unnecessary""" + pass + + def invalidate(self): + self.clear() # XXX probably needs to unset cookie + + # non-modifying dictionary methods + get = manage_accessed(dict.get) + __getitem__ = manage_accessed(dict.__getitem__) + items = manage_accessed(dict.items) + iteritems = manage_accessed(dict.iteritems) + values = manage_accessed(dict.values) + itervalues = manage_accessed(dict.itervalues) + keys = manage_accessed(dict.keys) + iterkeys = manage_accessed(dict.iterkeys) + __contains__ = manage_accessed(dict.__contains__) + has_key = manage_accessed(dict.has_key) + __len__ = manage_accessed(dict.__len__) + __iter__ = manage_accessed(dict.__iter__) + + # modifying dictionary methods + clear = manage_modified(dict.clear) + update = manage_modified(dict.update) + setdefault = manage_modified(dict.setdefault) + pop = manage_modified(dict.pop) + popitem = manage_modified(dict.popitem) + __setitem__ = manage_modified(dict.__setitem__) + __delitem__ = manage_modified(dict.__delitem__) + + # non-API methods + def _set_cookie(self, response): + if not self._cookie_on_exception: + exception = getattr(self.request, 'exception', None) + if exception is not None: # dont set a cookie during exceptions + return False + cookieval = serialize( + (self.accessed, self.created, self.modified, dict(self)), + self._secret + ) + if len(cookieval) > 4064: + raise ValueError( + 'Cookie value is too long to store (%s bytes)' % + len(cookieval) + ) + if hasattr(response, 'set_cookie'): + # ``response`` is a "real" webob response + set_cookie = response.set_cookie + else: + # ``response`` is not a "real" webob response, cope + def set_cookie(*arg, **kw): + tmp_response = Response() + tmp_response.set_cookie(*arg, **kw) + response.headerlist.append( + tmp_response.headerlist[-1]) + set_cookie( + self._cookie_name, + value=cookieval, + max_age = self._cookie_max_age, + path = self._cookie_path, + domain = self._cookie_domain, + secure = self._cookie_secure, + httponly = self._cookie_httponly, + ) + return True + + return InsecureCookieSessionFactory + +def serialize(data, secret): + pickled = pickle.dumps(data, pickle.HIGHEST_PROTOCOL) + sig = hmac.new(secret, pickled, sha1).hexdigest() + return sig + base64.standard_b64encode(pickled) + +def deserialize(serialized, secret, hmac=hmac): + # hmac parameterized only for unit tests + if serialized is None: + return None + + try: + input_sig, pickled = (serialized[:40], + base64.standard_b64decode(serialized[40:])) + except (binascii.Error, TypeError): + # Badly formed data can make base64 die + return None + + sig = hmac.new(secret, pickled, sha1).hexdigest() + + # Avoid timing attacks (note that this is cadged from Pylons and I + # have no idea what it means) + + if len(sig) != len(input_sig): + return None + + invalid_bits = 0 + + for a, b in zip(sig, input_sig): + invalid_bits += a != b + + if invalid_bits: + return None + + return pickle.loads(pickled) + diff --git a/pyramid/tests/test_configuration.py b/pyramid/tests/test_configuration.py index fa2318fcc..2ff6ed1e9 100644 --- a/pyramid/tests/test_configuration.py +++ b/pyramid/tests/test_configuration.py @@ -186,6 +186,11 @@ class ConfiguratorTests(unittest.TestCase): config = self._makeOne(default_permission='view') self.assertEqual(config.registry.getUtility(IDefaultPermission), 'view') + def test_ctor_session_factory(self): + from pyramid.interfaces import ISessionFactory + config = self._makeOne(session_factory='factory') + self.assertEqual(config.registry.getUtility(ISessionFactory), 'factory') + def test_with_package_module(self): from pyramid.tests import test_configuration import pyramid.tests @@ -2477,6 +2482,13 @@ class ConfiguratorTests(unittest.TestCase): self.assertEqual(config.registry.getUtility(IDefaultPermission), 'view') + def test_set_session_factory(self): + from pyramid.interfaces import ISessionFactory + config = self._makeOne() + config.set_session_factory('factory') + self.assertEqual(config.registry.getUtility(ISessionFactory), + 'factory') + def test_add_translation_dirs_missing_dir(self): from pyramid.exceptions import ConfigurationError config = self._makeOne() diff --git a/pyramid/tests/test_request.py b/pyramid/tests/test_request.py index 92edf2a51..93cbb9691 100644 --- a/pyramid/tests/test_request.py +++ b/pyramid/tests/test_request.py @@ -1,6 +1,14 @@ import unittest class TestRequest(unittest.TestCase): + def setUp(self): + from pyramid.configuration import Configurator + self.config = Configurator() + self.config.begin() + + def tearDown(self): + self.config.end() + def _makeOne(self, environ): return self._getTargetClass()(environ) @@ -35,6 +43,22 @@ class TestRequest(unittest.TestCase): inst = self._makeOne({}) self.assertTrue(IRequest.providedBy(inst)) + def test_session_configured(self): + from pyramid.interfaces import ISessionFactory + inst = self._makeOne({}) + def factory(request): + return 'orangejuice' + self.config.registry.registerUtility(factory, ISessionFactory) + inst.registry = self.config.registry + self.assertEqual(inst.session, 'orangejuice') + self.assertEqual(inst.__dict__['session'], 'orangejuice') + + def test_session_not_configured(self): + from pyramid.exceptions import ConfigurationError + inst = self._makeOne({}) + inst.registry = self.config.registry + self.assertRaises(ConfigurationError, inst.__getattr__, 'session') + def test_setattr_and_getattr_dotnotation(self): inst = self._makeOne({}) inst.foo = 1 diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py new file mode 100644 index 000000000..d8c1b2c00 --- /dev/null +++ b/pyramid/tests/test_session.py @@ -0,0 +1,252 @@ +import unittest +from pyramid import testing + +class TestInsecureCookieSession(unittest.TestCase): + def _makeOne(self, request, **kw): + from pyramid.session import InsecureCookieSessionFactoryConfig + return InsecureCookieSessionFactoryConfig('secret', **kw)(request) + + def test_ctor_no_cookie(self): + request = testing.DummyRequest() + session = self._makeOne(request) + self.assertEqual(dict(session), {}) + + def _serialize(self, accessed, state, secret='secret'): + from pyramid.session import serialize + return serialize((accessed, accessed, accessed, state), secret) + + def test_ctor_with_cookie_still_valid(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize(time.time(), {'state':1}) + request.cookies['session'] = cookieval + session = self._makeOne(request) + self.assertEqual(dict(session), {'state':1}) + + def test_ctor_with_cookie_expired(self): + request = testing.DummyRequest() + cookieval = self._serialize(0, {'state':1}) + request.cookies['session'] = cookieval + session = self._makeOne(request) + self.assertEqual(dict(session), {}) + + def test_changed(self): + request = testing.DummyRequest() + session = self._makeOne(request) + self.assertEqual(session.changed(), None) + + def test_invalidate(self): + request = testing.DummyRequest() + session = self._makeOne(request) + session['a'] = 1 + self.assertEqual(session.invalidate(), None) + self.failIf('a' in session) + + def test__set_cookie_on_exception(self): + request = testing.DummyRequest() + request.exception = True + session = self._makeOne(request) + session._cookie_on_exception = False + response = DummyResponse() + self.assertEqual(session._set_cookie(response), False) + + def test__set_cookie_cookieval_too_long(self): + request = testing.DummyRequest() + session = self._makeOne(request) + session['abc'] = 'x'*100000 + response = DummyResponse() + self.assertRaises(ValueError, session._set_cookie, response) + + def test__set_cookie_real_webob_response(self): + import webob + request = testing.DummyRequest() + session = self._makeOne(request) + session['abc'] = 'x' + response = webob.Response() + self.assertEqual(session._set_cookie(response), True) + self.assertEqual(response.headerlist[-1][0], 'Set-Cookie') + + def test__set_cookie_other_kind_of_response(self): + request = testing.DummyRequest() + request.exception = None + session = self._makeOne(request) + session['abc'] = 'x' + response = DummyResponse() + self.assertEqual(session._set_cookie(response), True) + self.assertEqual(len(response.headerlist), 1) + + def test__set_cookie_options(self): + request = testing.DummyRequest() + request.exception = None + session = self._makeOne(request, + cookie_name = 'abc', + cookie_path = '/foo', + cookie_domain = 'localhost', + cookie_secure = True, + cookie_httponly = True, + ) + session['abc'] = 'x' + response = DummyResponse() + self.assertEqual(session._set_cookie(response), True) + self.assertEqual(len(response.headerlist), 1) + cookieval= response.headerlist[0][1] + val, domain, path, secure, httponly = [x.strip() for x in + cookieval.split(';')] + self.failUnless(val.startswith('abc=')) + self.assertEqual(domain, 'Domain=localhost') + self.assertEqual(path, 'Path=/foo') + self.assertEqual(secure, 'secure') + self.assertEqual(httponly, 'HttpOnly') + +class Test_manage_accessed(unittest.TestCase): + def _makeOne(self, wrapped): + from pyramid.session import manage_accessed + return manage_accessed(wrapped) + + def test_accessed_set(self): + request = testing.DummyRequest() + session = DummySessionFactory(request) + session.accessed = None + wrapper = self._makeOne(session.__class__.get) + wrapper(session, 'a') + self.assertNotEqual(session.accessed, None) + + def test_already_dirty(self): + request = testing.DummyRequest() + session = DummySessionFactory(request) + session._dirty = True + session['a'] = 1 + wrapper = self._makeOne(session.__class__.get) + self.assertEqual(wrapper.__doc__, session.get.__doc__) + result = wrapper(session, 'a') + self.assertEqual(result, 1) + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 0) + + def test_with_exception(self): + import webob + request = testing.DummyRequest() + request.exception = True + session = DummySessionFactory(request) + session['a'] = 1 + wrapper = self._makeOne(session.__class__.get) + self.assertEqual(wrapper.__doc__, session.get.__doc__) + result = wrapper(session, 'a') + self.assertEqual(result, 1) + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = webob.Response() + result = callbacks[0](request, response) + self.assertEqual(result, None) + self.failIf('Set-Cookie' in dict(response.headerlist)) + + def test_cookie_is_set(self): + request = testing.DummyRequest() + session = DummySessionFactory(request) + session['a'] = 1 + wrapper = self._makeOne(session.__class__.get) + self.assertEqual(wrapper.__doc__, session.get.__doc__) + result = wrapper(session, 'a') + self.assertEqual(result, 1) + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = DummyResponse() + result = callbacks[0](request, response) + self.assertEqual(result, None) + self.assertEqual(session.response, response) + +class Test_manage_modified(Test_manage_accessed): + def _makeOne(self, wrapped): + from pyramid.session import manage_modified + return manage_modified(wrapped) + + def test_modified_set(self): + request = testing.DummyRequest() + session = DummySessionFactory(request) + session.modified = None + session.accessed = None + wrapper = self._makeOne(session.__class__.__setitem__) + wrapper(session, 'a', 1) + self.assertNotEqual(session.accessed, None) + self.assertNotEqual(session.modified, None) + +def serialize(data, secret): + try: + from hashlib import sha1 + except ImportError: # pragma: no cover + import sha as sha1 + + try: + import cPickle as pickle + except ImportError: # pragma: no cover + import pickle + + import hmac + import base64 + pickled = pickle.dumps('123', pickle.HIGHEST_PROTOCOL) + sig = hmac.new(secret, pickled, sha1).hexdigest() + return sig + base64.standard_b64encode(pickled) + +class Test_serialize(unittest.TestCase): + def _callFUT(self, data, secret): + from pyramid.session import serialize + return serialize(data, secret) + + def test_it(self): + expected = serialize('123', 'secret') + result = self._callFUT('123', 'secret') + self.assertEqual(result, expected) + +class Test_deserialize(unittest.TestCase): + def _callFUT(self, serialized, secret, hmac=None): + if hmac is None: + import hmac + from pyramid.session import deserialize + return deserialize(serialized, secret, hmac=hmac) + + def test_it(self): + serialized = serialize('123', 'secret') + result = self._callFUT(serialized, 'secret') + self.assertEqual(result, '123') + + def test_invalid_bits(self): + serialized = serialize('123', 'secret') + result = self._callFUT(serialized, 'seekrit') + self.assertEqual(result, None) + + def test_invalid_len(self): + class hmac(object): + def new(self, *arg): + return self + def hexdigest(self): + return '1234' + serialized = serialize('123', 'secret123') + result = self._callFUT(serialized, 'secret', hmac=hmac()) + self.assertEqual(result, None) + + def test_it_bad_encoding(self): + serialized = 'bad' + serialize('123', 'secret') + result = self._callFUT(serialized, 'secret') + self.assertEqual(result, None) + + +class DummySessionFactory(dict): + _dirty = False + _cookie_name = 'session' + _cookie_max_age = None + _cookie_path = '/' + _cookie_domain = None + _cookie_secure = False + _cookie_httponly = False + _timeout = 1200 + _secret = 'secret' + def __init__(self, request): + self.request = request + dict.__init__(self, {}) + + def _set_cookie(self, response): + self.response = response + +class DummyResponse(object): + def __init__(self): + self.headerlist = [] |
