diff --git a/tailbone/app.py b/tailbone/app.py index 8a665807..e7d1008d 100644 --- a/tailbone/app.py +++ b/tailbone/app.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # @@ -133,7 +133,7 @@ def make_pyramid_config(settings): config.set_default_csrf_options(require_csrf=True, token='_csrf') # Bring in some Pyramid goodies. - config.include('pyramid_beaker') + config.include('tailbone.beaker') config.include('pyramid_mako') config.include('pyramid_tm') diff --git a/tailbone/beaker.py b/tailbone/beaker.py new file mode 100644 index 00000000..9a15ba80 --- /dev/null +++ b/tailbone/beaker.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2017 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +Custom sessions, based on Beaker + +Note that most of the code for this module was copied from the beaker and +pyramid_beaker projects. +""" + +from __future__ import unicode_literals, absolute_import + +import time + +from beaker.session import Session +from beaker.util import coerce_session_params +from pyramid.settings import asbool +from pyramid_beaker import BeakerSessionFactoryConfig, set_cache_regions_from_settings + + +class TailboneSession(Session): + """ + Custom session class for Beaker, which overrides load() to add per-request + session timeout support. + """ + + def load(self): + "Loads the data from this session from persistent storage" + self.namespace = self.namespace_class(self.id, + data_dir=self.data_dir, + digest_filenames=False, + **self.namespace_args) + now = time.time() + if self.use_cookies: + self.request['set_cookie'] = True + + self.namespace.acquire_read_lock() + timed_out = False + try: + self.clear() + try: + session_data = self.namespace['session'] + + if (session_data is not None and self.encrypt_key): + session_data = self._decrypt_data(session_data) + + # Memcached always returns a key, its None when its not + # present + if session_data is None: + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + except (KeyError, TypeError): + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + + if session_data is None or len(session_data) == 0: + session_data = { + '_creation_time': now, + '_accessed_time': now + } + self.is_new = True + + # TODO: sure would be nice if we could get this little bit of logic + # into the upstream Beaker package, as that would avoid the need + # for this module entirely... + timeout = session_data.get('_timeout', self.timeout) + if timeout is not None and \ + now - session_data['_accessed_time'] > timeout: + timed_out = True + else: + # Properly set the last_accessed time, which is different + # than the *currently* _accessed_time + if self.is_new or '_accessed_time' not in session_data: + self.last_accessed = None + else: + self.last_accessed = session_data['_accessed_time'] + + # Update the current _accessed_time + session_data['_accessed_time'] = now + + # Set the path if applicable + if '_path' in session_data: + self._path = session_data['_path'] + self.update(session_data) + self.accessed_dict = session_data.copy() + finally: + self.namespace.release_read_lock() + if timed_out: + self.invalidate() + + +def session_factory_from_settings(settings): + """ Return a Pyramid session factory using Beaker session settings + supplied from a Paste configuration file""" + prefixes = ('session.', 'beaker.session.') + options = {} + + # Pull out any config args meant for beaker session. if there are any + for k, v in settings.items(): + for prefix in prefixes: + if k.startswith(prefix): + option_name = k[len(prefix):] + if option_name == 'cookie_on_exception': + v = asbool(v) + options[option_name] = v + + options = coerce_session_params(options) + options['session_class'] = TailboneSession + return BeakerSessionFactoryConfig(**options) + + +def includeme(config): + session_factory = session_factory_from_settings(config.registry.settings) + config.set_session_factory(session_factory) + set_cache_regions_from_settings(config.registry.settings) diff --git a/tailbone/subscribers.py b/tailbone/subscribers.py index c33b8a10..fd7f0f26 100644 --- a/tailbone/subscribers.py +++ b/tailbone/subscribers.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2016 Lance Edgar +# Copyright © 2010-2017 Lance Edgar # # This file is part of Rattail. # @@ -28,7 +28,7 @@ from __future__ import unicode_literals, absolute_import import rattail from rattail.db import model -from rattail.db.auth import has_permission, administrator_role +from rattail.db.auth import has_permission from pyramid import threadlocal @@ -126,7 +126,7 @@ def context_found(event): if request.user: Session().set_continuum_user(request.user) - request.is_admin = request.user and administrator_role(Session()) in request.user.roles + request.is_admin = bool(request.user) and request.user.is_admin() request.is_root = request.is_admin and request.session.get('is_root', False) def has_perm(name): diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py index 13c27ca4..d8d1aecf 100644 --- a/tailbone/views/auth.py +++ b/tailbone/views/auth.py @@ -26,7 +26,9 @@ Auth Views from __future__ import unicode_literals, absolute_import -from rattail.db.auth import authenticate_user, set_user_password +import logging + +from rattail.db.auth import authenticate_user, set_user_password, has_permission import formencode as fe from pyramid.httpexceptions import HTTPForbidden @@ -39,6 +41,9 @@ from tailbone.db import Session from tailbone.views import View +log = logging.getLogger(__name__) + + class UserLogin(fe.Schema): allow_extra_fields = True filter_extra_fields = True @@ -106,11 +111,17 @@ class AuthenticationView(View): user = self.authenticate_user(form.data['username'], form.data['password']) if user: + # okay now they're truly logged in headers = remember(self.request, user.uuid) + timeout = self.get_session_timeout_for_user(user) or None + log.debug("setting session timeout for '{}' to {}".format(user.username, timeout)) + self.set_session_timeout(timeout) + # treat URL from session as referrer, if available referrer = self.request.session.pop('next_url', referrer) return self.redirect(referrer, headers=headers) + else: self.request.session.flash("Invalid username or password", 'error') @@ -123,6 +134,33 @@ class AuthenticationView(View): def authenticate_user(self, username, password): return authenticate_user(Session(), username, password) + def get_session_timeout_for_user(self, user): + """ + Must return a value to be used to set the session timeout for the given + user. By default this will return ``None`` if the user has the + "forever session" permission, otherwise will try to read a default + value from config: + + .. code-block:: ini + + [tailbone] + + # set session timeout to 10 minutes: + session.default_timeout = 600 + + # or, set to 0 to disable: + #session.default_timeout = 0 + """ + if not has_permission(Session(), user, 'general.forever_session'): + return self.rattail_config.getint('tailbone', 'session.default_timeout', + default=300) # 5 minutes + + def set_session_timeout(self, timeout): + """ + Set the server-side session timeout to the given value. + """ + self.request.session['_timeout'] = timeout or None + def mobile_login(self): return self.login(mobile=True) diff --git a/tailbone/views/common.py b/tailbone/views/common.py index 23f366db..8467a848 100644 --- a/tailbone/views/common.py +++ b/tailbone/views/common.py @@ -118,6 +118,11 @@ class CommonView(View): # auto-correct URLs which require trailing slash config.add_notfound_view(cls, attr='notfound', append_slash=True) + # general permissions + config.add_tailbone_permission_group('general', "(General)", overwrite=False) + config.add_tailbone_permission('general', 'general.forever_session', + "Never expire sessions on server") + # home config.add_route('home', '/') config.add_view(cls, attr='home', route_name='home', renderer='/home.mako')