Home | History | Annotate | Download | only in webapp2_extras
      1 # -*- coding: utf-8 -*-
      2 """
      3     webapp2_extras.sessions
      4     =======================
      5 
      6     Lightweight but flexible session support for webapp2.
      7 
      8     :copyright: 2011 by tipfy.org.
      9     :license: Apache Sotware License, see LICENSE for details.
     10 """
     11 import re
     12 
     13 import webapp2
     14 
     15 from webapp2_extras import securecookie
     16 from webapp2_extras import security
     17 
     18 #: Default configuration values for this module. Keys are:
     19 #:
     20 #: secret_key
     21 #:     Secret key to generate session cookies. Set this to something random
     22 #:     and unguessable. This is the only required configuration key:
     23 #:     an exception is raised if it is not defined.
     24 #:
     25 #: cookie_name
     26 #:     Name of the cookie to save a session or session id. Default is
     27 #:     `session`.
     28 #:
     29 #: session_max_age:
     30 #:     Default session expiration time in seconds. Limits the duration of the
     31 #:     contents of a cookie, even if a session cookie exists. If None, the
     32 #:     contents lasts as long as the cookie is valid. Default is None.
     33 #:
     34 #: cookie_args
     35 #:     Default keyword arguments used to set a cookie. Keys are:
     36 #:
     37 #:     - max_age: Cookie max age in seconds. Limits the duration
     38 #:       of a session cookie. If None, the cookie lasts until the client
     39 #:       is closed. Default is None.
     40 #:
     41 #:     - domain: Domain of the cookie. To work accross subdomains the
     42 #:       domain must be set to the main domain with a preceding dot, e.g.,
     43 #:       cookies set for `.mydomain.org` will work in `foo.mydomain.org` and
     44 #:       `bar.mydomain.org`. Default is None, which means that cookies will
     45 #:       only work for the current subdomain.
     46 #:
     47 #:     - path: Path in which the authentication cookie is valid.
     48 #:       Default is `/`.
     49 #:
     50 #:     - secure: Make the cookie only available via HTTPS.
     51 #:
     52 #:     - httponly: Disallow JavaScript to access the cookie.
     53 #:
     54 #: backends
     55 #:     A dictionary of available session backend classes used by
     56 #:     :meth:`SessionStore.get_session`.
     57 default_config = {
     58     'secret_key':      None,
     59     'cookie_name':     'session',
     60     'session_max_age': None,
     61     'cookie_args': {
     62         'max_age':     None,
     63         'domain':      None,
     64         'path':        '/',
     65         'secure':      None,
     66         'httponly':    False,
     67     },
     68     'backends': {
     69         'securecookie': 'webapp2_extras.sessions.SecureCookieSessionFactory',
     70         'datastore':    'webapp2_extras.appengine.sessions_ndb.' \
     71                         'DatastoreSessionFactory',
     72         'memcache':     'webapp2_extras.appengine.sessions_memcache.' \
     73                         'MemcacheSessionFactory',
     74     },
     75 }
     76 
     77 _default_value = object()
     78 
     79 
     80 class _UpdateDictMixin(object):
     81     """Makes dicts call `self.on_update` on modifications.
     82 
     83     From werkzeug.datastructures.
     84     """
     85 
     86     on_update = None
     87 
     88     def calls_update(name):
     89         def oncall(self, *args, **kw):
     90             rv = getattr(super(_UpdateDictMixin, self), name)(*args, **kw)
     91             if self.on_update is not None:
     92                 self.on_update()
     93             return rv
     94         oncall.__name__ = name
     95         return oncall
     96 
     97     __setitem__ = calls_update('__setitem__')
     98     __delitem__ = calls_update('__delitem__')
     99     clear = calls_update('clear')
    100     pop = calls_update('pop')
    101     popitem = calls_update('popitem')
    102     setdefault = calls_update('setdefault')
    103     update = calls_update('update')
    104     del calls_update
    105 
    106 
    107 class SessionDict(_UpdateDictMixin, dict):
    108     """A dictionary for session data."""
    109 
    110     __slots__ = ('container', 'new', 'modified')
    111 
    112     def __init__(self, container, data=None, new=False):
    113         self.container = container
    114         self.new = new
    115         self.modified = False
    116         dict.update(self, data or ())
    117 
    118     def pop(self, key, *args):
    119         # Only pop if key doesn't exist, do not alter the dictionary.
    120         if key in self:
    121             return super(SessionDict, self).pop(key, *args)
    122         if args:
    123             return args[0]
    124         raise KeyError(key)
    125 
    126     def on_update(self):
    127         self.modified = True
    128 
    129     def get_flashes(self, key='_flash'):
    130         """Returns a flash message. Flash messages are deleted when first read.
    131 
    132         :param key:
    133             Name of the flash key stored in the session. Default is '_flash'.
    134         :returns:
    135             The data stored in the flash, or an empty list.
    136         """
    137         return self.pop(key, [])
    138 
    139     def add_flash(self, value, level=None, key='_flash'):
    140         """Adds a flash message. Flash messages are deleted when first read.
    141 
    142         :param value:
    143             Value to be saved in the flash message.
    144         :param level:
    145             An optional level to set with the message. Default is `None`.
    146         :param key:
    147             Name of the flash key stored in the session. Default is '_flash'.
    148         """
    149         self.setdefault(key, []).append((value, level))
    150 
    151 
    152 class BaseSessionFactory(object):
    153     """Base class for all session factories."""
    154 
    155     #: Name of the session.
    156     name = None
    157     #: A reference to :class:`SessionStore`.
    158     session_store = None
    159     #: Keyword arguments to save the session.
    160     session_args = None
    161     #: The session data, a :class:`SessionDict` instance.
    162     session = None
    163 
    164     def __init__(self, name, session_store):
    165         self.name = name
    166         self.session_store = session_store
    167         self.session_args = session_store.config['cookie_args'].copy()
    168         self.session = None
    169 
    170     def get_session(self, max_age=_default_value):
    171         raise NotImplementedError()
    172 
    173     def save_session(self, response):
    174         raise NotImplementedError()
    175 
    176 
    177 class SecureCookieSessionFactory(BaseSessionFactory):
    178     """A session factory that stores data serialized in a signed cookie.
    179 
    180     Signed cookies can't be forged because the HMAC signature won't match.
    181 
    182     This is the default factory passed as the `factory` keyword to
    183     :meth:`SessionStore.get_session`.
    184 
    185     .. warning::
    186        The values stored in a signed cookie will be visible in the cookie,
    187        so do not use secure cookie sessions if you need to store data that
    188        can't be visible to users. For this, use datastore or memcache sessions.
    189     """
    190 
    191     def get_session(self, max_age=_default_value):
    192         if self.session is None:
    193             data = self.session_store.get_secure_cookie(self.name,
    194                                                         max_age=max_age)
    195             new = data is None
    196             self.session = SessionDict(self, data=data, new=new)
    197 
    198         return self.session
    199 
    200     def save_session(self, response):
    201         if self.session is None or not self.session.modified:
    202             return
    203 
    204         self.session_store.save_secure_cookie(
    205             response, self.name, dict(self.session), **self.session_args)
    206 
    207 
    208 class CustomBackendSessionFactory(BaseSessionFactory):
    209     """Base class for sessions that use custom backends, e.g., memcache."""
    210 
    211     #: The session unique id.
    212     sid = None
    213 
    214     #: Used to validate session ids.
    215     _sid_re = re.compile(r'^\w{22}$')
    216 
    217     def get_session(self, max_age=_default_value):
    218         if self.session is None:
    219             data = self.session_store.get_secure_cookie(self.name,
    220                                                         max_age=max_age)
    221             sid = data.get('_sid') if data else None
    222             self.session = self._get_by_sid(sid)
    223 
    224         return self.session
    225 
    226     def _get_by_sid(self, sid):
    227         raise NotImplementedError()
    228 
    229     def _is_valid_sid(self, sid):
    230         """Check if a session id has the correct format."""
    231         return sid and self._sid_re.match(sid) is not None
    232 
    233     def _get_new_sid(self):
    234         return security.generate_random_string(entropy=128)
    235 
    236 
    237 class SessionStore(object):
    238     """A session provider for a single request.
    239 
    240     The session store can provide multiple sessions using different keys,
    241     even using different backends in the same request, through the method
    242     :meth:`get_session`. By default it returns a session using the default key.
    243 
    244     To use, define a base handler that extends the dispatch() method to start
    245     the session store and save all sessions at the end of a request::
    246 
    247         import webapp2
    248 
    249         from webapp2_extras import sessions
    250 
    251         class BaseHandler(webapp2.RequestHandler):
    252             def dispatch(self):
    253                 # Get a session store for this request.
    254                 self.session_store = sessions.get_store(request=self.request)
    255 
    256                 try:
    257                     # Dispatch the request.
    258                     webapp2.RequestHandler.dispatch(self)
    259                 finally:
    260                     # Save all sessions.
    261                     self.session_store.save_sessions(self.response)
    262 
    263             @webapp2.cached_property
    264             def session(self):
    265                 # Returns a session using the default cookie key.
    266                 return self.session_store.get_session()
    267 
    268     Then just use the session as a dictionary inside a handler::
    269 
    270         # To set a value:
    271         self.session['foo'] = 'bar'
    272 
    273         # To get a value:
    274         foo = self.session.get('foo')
    275 
    276     A configuration dict can be passed to :meth:`__init__`, or the application
    277     must be initialized with the ``secret_key`` configuration defined. The
    278     configuration is a simple dictionary::
    279 
    280         config = {}
    281         config['webapp2_extras.sessions'] = {
    282             'secret_key': 'my-super-secret-key',
    283         }
    284 
    285         app = webapp2.WSGIApplication([
    286             ('/', HomeHandler),
    287         ], config=config)
    288 
    289     Other configuration keys are optional.
    290     """
    291 
    292     #: Configuration key.
    293     config_key = __name__
    294 
    295     def __init__(self, request, config=None):
    296         """Initializes the session store.
    297 
    298         :param request:
    299             A :class:`webapp2.Request` instance.
    300         :param config:
    301             A dictionary of configuration values to be overridden. See
    302             the available keys in :data:`default_config`.
    303         """
    304         self.request = request
    305         # Base configuration.
    306         self.config = request.app.config.load_config(self.config_key,
    307             default_values=default_config, user_values=config,
    308             required_keys=('secret_key',))
    309         # Tracked sessions.
    310         self.sessions = {}
    311 
    312     @webapp2.cached_property
    313     def serializer(self):
    314         # Serializer and deserializer for signed cookies.
    315         return securecookie.SecureCookieSerializer(self.config['secret_key'])
    316 
    317     def get_backend(self, name):
    318         """Returns a configured session backend, importing it if needed.
    319 
    320         :param name:
    321             The backend keyword.
    322         :returns:
    323             A :class:`BaseSessionFactory` subclass.
    324         """
    325         backends = self.config['backends']
    326         backend = backends[name]
    327         if isinstance(backend, basestring):
    328             backend = backends[name] = webapp2.import_string(backend)
    329 
    330         return backend
    331 
    332     # Backend based sessions --------------------------------------------------
    333 
    334     def _get_session_container(self, name, factory):
    335         if name not in self.sessions:
    336             self.sessions[name] = factory(name, self)
    337 
    338         return self.sessions[name]
    339 
    340     def get_session(self, name=None, max_age=_default_value, factory=None,
    341                     backend='securecookie'):
    342         """Returns a session for a given name. If the session doesn't exist, a
    343         new session is returned.
    344 
    345         :param name:
    346             Cookie name. If not provided, uses the ``cookie_name``
    347             value configured for this module.
    348         :param max_age:
    349             A maximum age in seconds for the session to be valid. Sessions
    350             store a timestamp to invalidate them if needed. If `max_age` is
    351             None, the timestamp won't be checked.
    352         :param factory:
    353             A session factory that creates the session using the preferred
    354             backend. For convenience, use the `backend` argument instead,
    355             which defines a backend keyword based on the configured ones.
    356         :param backend:
    357             A configured backend keyword. Available ones are:
    358 
    359             - ``securecookie``: uses secure cookies. This is the default
    360               backend.
    361             - ``datastore``: uses App Engine's datastore.
    362             - ``memcache``:  uses App Engine's memcache.
    363         :returns:
    364             A dictionary-like session object.
    365         """
    366         factory = factory or self.get_backend(backend)
    367         name = name or self.config['cookie_name']
    368 
    369         if max_age is _default_value:
    370             max_age = self.config['session_max_age']
    371 
    372         container = self._get_session_container(name, factory)
    373         return container.get_session(max_age=max_age)
    374 
    375     # Signed cookies ----------------------------------------------------------
    376 
    377     def get_secure_cookie(self, name, max_age=_default_value):
    378         """Returns a deserialized secure cookie value.
    379 
    380         :param name:
    381             Cookie name.
    382         :param max_age:
    383             Maximum age in seconds for a valid cookie. If the cookie is older
    384             than this, returns None.
    385         :returns:
    386             A secure cookie value or None if it is not set.
    387         """
    388         if max_age is _default_value:
    389             max_age = self.config['session_max_age']
    390 
    391         value = self.request.cookies.get(name)
    392         if value:
    393             return self.serializer.deserialize(name, value, max_age=max_age)
    394 
    395     def set_secure_cookie(self, name, value, **kwargs):
    396         """Sets a secure cookie to be saved.
    397 
    398         :param name:
    399             Cookie name.
    400         :param value:
    401             Cookie value. Must be a dictionary.
    402         :param kwargs:
    403             Options to save the cookie. See :meth:`get_session`.
    404         """
    405         assert isinstance(value, dict), 'Secure cookie values must be a dict.'
    406         container = self._get_session_container(name,
    407                                                 SecureCookieSessionFactory)
    408         container.get_session().update(value)
    409         container.session_args.update(kwargs)
    410 
    411     # Saving to a response object ---------------------------------------------
    412 
    413     def save_sessions(self, response):
    414         """Saves all sessions in a response object.
    415 
    416         :param response:
    417             A :class:`webapp.Response` object.
    418         """
    419         for session in self.sessions.values():
    420             session.save_session(response)
    421 
    422     def save_secure_cookie(self, response, name, value, **kwargs):
    423         value = self.serializer.serialize(name, value)
    424         response.set_cookie(name, value, **kwargs)
    425 
    426 
    427 # Factories -------------------------------------------------------------------
    428 
    429 
    430 #: Key used to store :class:`SessionStore` in the request registry.
    431 _registry_key = 'webapp2_extras.sessions.SessionStore'
    432 
    433 
    434 def get_store(factory=SessionStore, key=_registry_key, request=None):
    435     """Returns an instance of :class:`SessionStore` from the request registry.
    436 
    437     It'll try to get it from the current request registry, and if it is not
    438     registered it'll be instantiated and registered. A second call to this
    439     function will return the same instance.
    440 
    441     :param factory:
    442         The callable used to build and register the instance if it is not yet
    443         registered. The default is the class :class:`SessionStore` itself.
    444     :param key:
    445         The key used to store the instance in the registry. A default is used
    446         if it is not set.
    447     :param request:
    448         A :class:`webapp2.Request` instance used to store the instance. The
    449         active request is used if it is not set.
    450     """
    451     request = request or webapp2.get_request()
    452     store = request.registry.get(key)
    453     if not store:
    454         store = request.registry[key] = factory(request)
    455 
    456     return store
    457 
    458 
    459 def set_store(store, key=_registry_key, request=None):
    460     """Sets an instance of :class:`SessionStore` in the request registry.
    461 
    462     :param store:
    463         An instance of :class:`SessionStore`.
    464     :param key:
    465         The key used to retrieve the instance from the registry. A default
    466         is used if it is not set.
    467     :param request:
    468         A :class:`webapp2.Request` instance used to retrieve the instance. The
    469         active request is used if it is not set.
    470     """
    471     request = request or webapp2.get_request()
    472     request.registry[key] = store
    473 
    474 
    475 # Don't need to import it. :)
    476 default_config['backends']['securecookie'] = SecureCookieSessionFactory
    477