From 5907154a593bf5fc02c1e0fbc8afe683ac7d3602 Mon Sep 17 00:00:00 2001 From: Elrond Date: Fri, 22 Mar 2013 18:46:47 +0100 Subject: [PATCH 01/10] Basic itsdangerous infrastructure. Implement the basic infrastructure for using itsdangerous in mediagoblin. Usage instructions will follow. --- mediagoblin/app.py | 3 ++ mediagoblin/config_spec.ini | 3 ++ mediagoblin/tools/crypto.py | 55 +++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 mediagoblin/tools/crypto.py diff --git a/mediagoblin/app.py b/mediagoblin/app.py index bb6be4d4..515b5b66 100644 --- a/mediagoblin/app.py +++ b/mediagoblin/app.py @@ -36,6 +36,7 @@ from mediagoblin.init import (get_jinja_loader, get_staticdirector, setup_global_and_app_config, setup_locales, setup_workbench, setup_database, setup_storage, setup_beaker_cache) from mediagoblin.tools.pluginapi import PluginManager +from mediagoblin.tools.crypto import setup_crypto _log = logging.getLogger(__name__) @@ -66,6 +67,8 @@ class MediaGoblinApp(object): # Open and setup the config global_config, app_config = setup_global_and_app_config(config_path) + setup_crypto() + ########################################## # Setup other connections / useful objects ########################################## diff --git a/mediagoblin/config_spec.ini b/mediagoblin/config_spec.ini index 44f6a68f..8c9c87c8 100644 --- a/mediagoblin/config_spec.ini +++ b/mediagoblin/config_spec.ini @@ -14,6 +14,9 @@ sql_engine = string(default="sqlite:///%(here)s/mediagoblin.db") # Where temporary files used in processing and etc are kept workbench_path = string(default="%(here)s/user_dev/media/workbench") +# Where to store cryptographic sensible data +crypto_path = string(default="%(here)s/user_dev/crypto") + # Where mediagoblin-builtin static assets are kept direct_remote_path = string(default="/mgoblin_static/") diff --git a/mediagoblin/tools/crypto.py b/mediagoblin/tools/crypto.py new file mode 100644 index 00000000..46752b55 --- /dev/null +++ b/mediagoblin/tools/crypto.py @@ -0,0 +1,55 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 MediaGoblin contributors. See AUTHORS. +# +# This program 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. +# +# This program 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 this program. If not, see . + +import os.path +import logging +import random +import itsdangerous +from mediagoblin import mg_globals + +_log = logging.getLogger(__name__) + + +# Use the system (hardware-based) random number generator if it exists. +# -- this optimization is lifted from Django +if hasattr(random, 'SystemRandom'): + getrandbits = random.SystemRandom().getrandbits +else: + getrandbits = random.getrandbits + + +__itsda_secret = None + + +def setup_crypto(): + global __itsda_secret + dir = mg_globals.app_config["crypto_path"] + if not os.path.isdir(dir): + _log.info("Creating %s", dir) + os.makedirs(dir) + name = os.path.join(dir, "itsdangeroussecret.bin") + if os.path.exists(name): + __itsda_secret = file(name, "r").read() + else: + __itsda_secret = str(getrandbits(192)) + file(name, "w").write(__itsda_secret) + _log.info("Created %s", name) + + +def get_timed_signer_url(namespace): + assert __itsda_secret is not None + return itsdangerous.URLSafeTimedSerializer(__itsda_secret, + salt=namespace) From 5a8aae3abac43fdebe6818330ad3c5d951de42b9 Mon Sep 17 00:00:00 2001 From: Elrond Date: Fri, 22 Mar 2013 19:09:19 +0100 Subject: [PATCH 02/10] Docs for get_timed_signer_url. --- mediagoblin/tools/crypto.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/mediagoblin/tools/crypto.py b/mediagoblin/tools/crypto.py index 46752b55..3294f135 100644 --- a/mediagoblin/tools/crypto.py +++ b/mediagoblin/tools/crypto.py @@ -50,6 +50,32 @@ def setup_crypto(): def get_timed_signer_url(namespace): + """ + This gives a basic signing/verifying object. + + The namespace makes sure signed tokens can't be used in + a different area. Like using a forgot-password-token as + a session cookie. + + Basic usage: + + .. code-block:: python + + _signer = None + TOKEN_VALID_DAYS = 10 + def setup(): + global _signer + _signer = get_timed_signer_url("session cookie") + def create_token(obj): + return _signer.dumps(obj) + def parse_token(token): + # This might raise an exception in case + # of an invalid token, or an expired token. + return _signer.loads(token, max_age=TOKEN_VALID_DAYS*24*3600) + + For more details see + http://pythonhosted.org/itsdangerous/#itsdangerous.URLSafeTimedSerializer + """ assert __itsda_secret is not None return itsdangerous.URLSafeTimedSerializer(__itsda_secret, salt=namespace) From bb530c44450b88c3584f4e50119857599e5a5f40 Mon Sep 17 00:00:00 2001 From: Elrond Date: Fri, 22 Mar 2013 19:12:55 +0100 Subject: [PATCH 03/10] Improve fs security for itsdangerous secret. Set mode 700 on the directory, mode 600 on the file. --- mediagoblin/tools/crypto.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mediagoblin/tools/crypto.py b/mediagoblin/tools/crypto.py index 3294f135..0fb2ba2e 100644 --- a/mediagoblin/tools/crypto.py +++ b/mediagoblin/tools/crypto.py @@ -38,14 +38,18 @@ def setup_crypto(): global __itsda_secret dir = mg_globals.app_config["crypto_path"] if not os.path.isdir(dir): - _log.info("Creating %s", dir) os.makedirs(dir) + os.chmod(dir, 0700) + _log.info("Created %s", dir) name = os.path.join(dir, "itsdangeroussecret.bin") if os.path.exists(name): __itsda_secret = file(name, "r").read() else: __itsda_secret = str(getrandbits(192)) - file(name, "w").write(__itsda_secret) + f = file(name, "w") + f.write(__itsda_secret) + f.close() + os.chmod(name, 0600) _log.info("Created %s", name) From c7424612d7c0447373dce8d69aa5af03aebe08dc Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sun, 24 Mar 2013 14:44:41 -0400 Subject: [PATCH 04/10] Back sessions with It's Dangerous. This is a contribution to #668. --- mediagoblin/app.py | 7 +++-- mediagoblin/tools/request.py | 2 +- mediagoblin/tools/session.py | 60 ++++++++++++++++++++++++++++++++++++ setup.py | 1 + 4 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 mediagoblin/tools/session.py diff --git a/mediagoblin/app.py b/mediagoblin/app.py index 515b5b66..fe8e8c4b 100644 --- a/mediagoblin/app.py +++ b/mediagoblin/app.py @@ -25,7 +25,7 @@ from werkzeug.exceptions import HTTPException from werkzeug.routing import RequestRedirect from mediagoblin import meddleware, __version__ -from mediagoblin.tools import common, translate, template +from mediagoblin.tools import common, session, translate, template from mediagoblin.tools.response import render_http_exception from mediagoblin.tools.theme import register_themes from mediagoblin.tools import request as mg_request @@ -160,7 +160,8 @@ class MediaGoblinApp(object): ## Attach utilities to the request object # Do we really want to load this via middleware? Maybe? - request.session = request.environ['beaker.session'] + session_manager = session.SessionManager() + request.session = session_manager.load_session_from_cookie(request) # Attach self as request.app # Also attach a few utilities from request.app for convenience? request.app = self @@ -229,6 +230,8 @@ class MediaGoblinApp(object): response = render_http_exeption( request, e, e.get_description(environ)) + session_manager.save_session_to_cookie(request.session, response) + return response(environ, start_response) def __call__(self, environ, start_response): diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py index bc67b96f..ee342eae 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -35,4 +35,4 @@ def setup_user_in_request(request): # Something's wrong... this user doesn't exist? Invalidate # this session. _log.warn("Killing session for user id %r", request.session['user_id']) - request.session.invalidate() + request.session.delete() diff --git a/mediagoblin/tools/session.py b/mediagoblin/tools/session.py new file mode 100644 index 00000000..676bc43e --- /dev/null +++ b/mediagoblin/tools/session.py @@ -0,0 +1,60 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 MediaGoblin contributors. See AUTHORS. +# +# This program 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. +# +# This program 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 this program. If not, see . + +import itsdangerous +import logging + +import crypto + +_log = logging.getLogger(__name__) + +class Session(dict): + def save(self): + self.send_new_cookie = True + + def is_updated(self): + return getattr(self, 'send_new_cookie') + + def delete(self): + self.clear() + self.save() + + +class SessionManager(object): + def __init__(self, cookie_name='MGSession', namespace=None): + if namespace is None: + namespace = cookie_name + self.signer = crypto.get_timed_signer_url(namespace) + self.cookie_name = cookie_name + + def load_session_from_cookie(self, request): + cookie = request.cookies.get(self.cookie_name) + if not cookie: + return Session() + ### FIXME: Future cookie-blacklisting code + # m = BadCookie.query.filter_by(cookie = cookie) + # if m: + # _log.warn("Bad cookie received: %s", m.reason) + # raise BadRequest() + try: + return Session(self.signer.loads(cookie)) + except itsdangerous.BadData: + return Session() + + def save_session_to_cookie(self, session, response): + if not session.is_updated: + return + response.set_cookie(self.cookie_name, self.signer.dumps(session)) diff --git a/setup.py b/setup.py index 9c295dc4..3593604e 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ setup( 'sqlalchemy>=0.7.0', 'sqlalchemy-migrate', 'mock', + 'itsdangerous', ## This is optional! # 'translitcodec', ## For now we're expecting that users will install this from From 627a721cf6819a118997e3e3f448e89903dacbf2 Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sun, 24 Mar 2013 14:47:02 -0400 Subject: [PATCH 05/10] Delete the session cookie on an empty session. --- mediagoblin/tools/session.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mediagoblin/tools/session.py b/mediagoblin/tools/session.py index 676bc43e..303907b9 100644 --- a/mediagoblin/tools/session.py +++ b/mediagoblin/tools/session.py @@ -57,4 +57,7 @@ class SessionManager(object): def save_session_to_cookie(self, session, response): if not session.is_updated: return - response.set_cookie(self.cookie_name, self.signer.dumps(session)) + elif not session: + response.delete_cookie(self.cookie_name) + else: + response.set_cookie(self.cookie_name, self.signer.dumps(session)) From 9e1fa2396fa4d340e3bcf01116cd1e2b6e5dee51 Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sun, 24 Mar 2013 15:10:08 -0400 Subject: [PATCH 06/10] Remove beaker stuff from the code. This is all obsoleted by It's Dangerous. --- mediagoblin/app.py | 5 +--- mediagoblin/init/__init__.py | 15 ---------- mediagoblin/mg_globals.py | 3 -- mediagoblin/tests/test_cache.py | 52 --------------------------------- mediagoblin/tests/tools.py | 4 +-- paste.ini | 2 +- setup.py | 1 - 7 files changed, 3 insertions(+), 79 deletions(-) delete mode 100644 mediagoblin/tests/test_cache.py diff --git a/mediagoblin/app.py b/mediagoblin/app.py index fe8e8c4b..2c772fe1 100644 --- a/mediagoblin/app.py +++ b/mediagoblin/app.py @@ -34,7 +34,7 @@ from mediagoblin.init.celery import setup_celery_from_config from mediagoblin.init.plugins import setup_plugins from mediagoblin.init import (get_jinja_loader, get_staticdirector, setup_global_and_app_config, setup_locales, setup_workbench, setup_database, - setup_storage, setup_beaker_cache) + setup_storage) from mediagoblin.tools.pluginapi import PluginManager from mediagoblin.tools.crypto import setup_crypto @@ -103,9 +103,6 @@ class MediaGoblinApp(object): # set up staticdirector tool self.staticdirector = get_staticdirector(app_config) - # set up caching - self.cache = setup_beaker_cache() - # Setup celery, if appropriate if setup_celery and not app_config.get('celery_setup_elsewhere'): if os.environ.get('CELERY_ALWAYS_EAGER', 'false').lower() == 'true': diff --git a/mediagoblin/init/__init__.py b/mediagoblin/init/__init__.py index 7c832442..d16027db 100644 --- a/mediagoblin/init/__init__.py +++ b/mediagoblin/init/__init__.py @@ -14,8 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from beaker.cache import CacheManager -from beaker.util import parse_cache_config_options import jinja2 from mediagoblin.tools import staticdirect @@ -146,16 +144,3 @@ def setup_workbench(): workbench_manager = WorkbenchManager(app_config['workbench_path']) setup_globals(workbench_manager=workbench_manager) - - -def setup_beaker_cache(): - """ - Setup the Beaker Cache manager. - """ - cache_config = mg_globals.global_config['beaker.cache'] - cache_config = dict( - [(u'cache.%s' % key, value) - for key, value in cache_config.iteritems()]) - cache = CacheManager(**parse_cache_config_options(cache_config)) - setup_globals(cache=cache) - return cache diff --git a/mediagoblin/mg_globals.py b/mediagoblin/mg_globals.py index e4b94bdc..26ed66fa 100644 --- a/mediagoblin/mg_globals.py +++ b/mediagoblin/mg_globals.py @@ -29,9 +29,6 @@ import threading # SQL database engine database = None -# beaker's cache manager -cache = None - # should be the same as the public_store = None queue_store = None diff --git a/mediagoblin/tests/test_cache.py b/mediagoblin/tests/test_cache.py deleted file mode 100644 index 48fa1386..00000000 --- a/mediagoblin/tests/test_cache.py +++ /dev/null @@ -1,52 +0,0 @@ -# GNU MediaGoblin -- federated, autonomous media hosting -# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. -# -# This program 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. -# -# This program 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 this program. If not, see . - - -from mediagoblin.tests.tools import setup_fresh_app -from mediagoblin import mg_globals - - -DATA_TO_CACHE = { - 'herp': 'derp', - 'lol': 'cats'} - - -def _get_some_data(key): - """ - Stuid function that makes use of some caching. - """ - some_data_cache = mg_globals.cache.get_cache('sum_data') - if some_data_cache.has_key(key): - return some_data_cache.get(key) - - value = DATA_TO_CACHE.get(key) - some_data_cache.put(key, value) - return value - - -@setup_fresh_app -def test_cache_working(test_app): - some_data_cache = mg_globals.cache.get_cache('sum_data') - assert not some_data_cache.has_key('herp') - assert _get_some_data('herp') == 'derp' - assert some_data_cache.get('herp') == 'derp' - # should get the same value again - assert _get_some_data('herp') == 'derp' - - # now we force-change it, but the function should use the cached - # version - some_data_cache.put('herp', 'pred') - assert _get_some_data('herp') == 'pred' diff --git a/mediagoblin/tests/tools.py b/mediagoblin/tests/tools.py index cc4a7add..1d8e6e96 100644 --- a/mediagoblin/tests/tools.py +++ b/mediagoblin/tests/tools.py @@ -45,9 +45,7 @@ TEST_USER_DEV = pkg_resources.resource_filename( 'mediagoblin.tests', 'test_user_dev') MGOBLIN_APP = None -USER_DEV_DIRECTORIES_TO_SETUP = [ - 'media/public', 'media/queue', - 'beaker/sessions/data', 'beaker/sessions/lock'] +USER_DEV_DIRECTORIES_TO_SETUP = ['media/public', 'media/queue'] BAD_CELERY_MESSAGE = """\ Sorry, you *absolutely* must run nosetests with the diff --git a/paste.ini b/paste.ini index 103bb609..1f0ee1b5 100644 --- a/paste.ini +++ b/paste.ini @@ -17,7 +17,7 @@ use = egg:Paste#urlmap [app:mediagoblin] use = egg:mediagoblin#app -filter-with = beaker +# filter-with = beaker config = %(here)s/mediagoblin_local.ini %(here)s/mediagoblin.ini [loggers] diff --git a/setup.py b/setup.py index 3593604e..1aebeb0f 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,6 @@ setup( install_requires=[ 'setuptools', 'PasteScript', - 'beaker', 'wtforms', 'py-bcrypt', 'nose', From 5d1a8815d12592d575816dec6212820e57303d3f Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sun, 24 Mar 2013 15:39:49 -0400 Subject: [PATCH 07/10] Set a starting value for session.send_new_cookie. This makes session.__init__ slightly more complicated but probably simplifies everything else, especially if we make the class smarter later by having it track changes itself. --- mediagoblin/tools/session.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mediagoblin/tools/session.py b/mediagoblin/tools/session.py index 303907b9..da53ddf0 100644 --- a/mediagoblin/tools/session.py +++ b/mediagoblin/tools/session.py @@ -22,11 +22,15 @@ import crypto _log = logging.getLogger(__name__) class Session(dict): + def __init__(self, *args, **kwargs): + self.send_new_cookie = False + dict.__init__(self, *args, **kwargs) + def save(self): self.send_new_cookie = True def is_updated(self): - return getattr(self, 'send_new_cookie') + return self.send_new_cookie def delete(self): self.clear() From e84e1cdf12d2ac6e5fdd6bf6fac4cfab29065cee Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sun, 24 Mar 2013 15:46:10 -0400 Subject: [PATCH 08/10] First tests for the Session class. --- mediagoblin/tests/test_session.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 mediagoblin/tests/test_session.py diff --git a/mediagoblin/tests/test_session.py b/mediagoblin/tests/test_session.py new file mode 100644 index 00000000..78d790eb --- /dev/null +++ b/mediagoblin/tests/test_session.py @@ -0,0 +1,30 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program 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. +# +# This program 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 this program. If not, see . + +from mediagoblin.tools import session + +def test_session(): + sess = session.Session() + assert not sess + assert not sess.is_updated() + sess['user_id'] = 27 + assert sess + assert not sess.is_updated() + sess.save() + assert sess.is_updated() + sess.delete() + assert not sess + assert sess.is_updated() From 09102e0767d3c24e0be7988dc22113993cbd3d3d Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sun, 24 Mar 2013 16:27:20 -0400 Subject: [PATCH 09/10] Harden It's Dangerous key management. The previous code was theoretically subject to timing attacks, where an attacker could read the key in between the time it was saved to the file and when the chmod happened. This version prevents that by using umasks to ensure the files always have the right permissions. This version also avoids using a key that cannot be saved due to some system setup bug. --- mediagoblin/tools/crypto.py | 67 +++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/mediagoblin/tools/crypto.py b/mediagoblin/tools/crypto.py index 0fb2ba2e..55811aea 100644 --- a/mediagoblin/tools/crypto.py +++ b/mediagoblin/tools/crypto.py @@ -14,10 +14,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import os.path -import logging -import random +import errno import itsdangerous +import logging +import os.path +import random +import tempfile from mediagoblin import mg_globals _log = logging.getLogger(__name__) @@ -25,33 +27,56 @@ _log = logging.getLogger(__name__) # Use the system (hardware-based) random number generator if it exists. # -- this optimization is lifted from Django -if hasattr(random, 'SystemRandom'): +try: getrandbits = random.SystemRandom().getrandbits -else: +except AttributeError: getrandbits = random.getrandbits __itsda_secret = None +def load_key(filename): + global __itsda_secret + key_file = open(filename) + try: + __itsda_secret = key_file.read() + finally: + key_file.close() + +def create_key(key_dir, key_filepath): + global __itsda_secret + old_umask = os.umask(077) + key_file = None + try: + if not os.path.isdir(key_dir): + os.makedirs(key_dir) + _log.info("Created %s", dirname) + key = str(getrandbits(192)) + key_file = tempfile.NamedTemporaryFile(dir=key_dir, suffix='.bin', + delete=False) + key_file.write(key) + key_file.flush() + os.rename(key_file.name, key_filepath) + key_file.close() + finally: + os.umask(old_umask) + if (key_file is not None) and (not key_file.closed): + key_file.close() + os.unlink(key_file.name) + __itsda_secret = key + _log.info("Saved new key for It's Dangerous") + def setup_crypto(): global __itsda_secret - dir = mg_globals.app_config["crypto_path"] - if not os.path.isdir(dir): - os.makedirs(dir) - os.chmod(dir, 0700) - _log.info("Created %s", dir) - name = os.path.join(dir, "itsdangeroussecret.bin") - if os.path.exists(name): - __itsda_secret = file(name, "r").read() - else: - __itsda_secret = str(getrandbits(192)) - f = file(name, "w") - f.write(__itsda_secret) - f.close() - os.chmod(name, 0600) - _log.info("Created %s", name) - + key_dir = mg_globals.app_config["crypto_path"] + key_filepath = os.path.join(key_dir, 'itsdangeroussecret.bin') + try: + load_key(key_filepath) + except IOError, error: + if error.errno != errno.ENOENT: + raise + create_key(key_dir, key_filepath) def get_timed_signer_url(namespace): """ From 3843697c288d1f6447745fd3c1beafd1732c1431 Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Mon, 25 Mar 2013 09:20:46 -0400 Subject: [PATCH 10/10] Call is_updated instead of testing it boolean. --- mediagoblin/tools/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mediagoblin/tools/session.py b/mediagoblin/tools/session.py index da53ddf0..d452b851 100644 --- a/mediagoblin/tools/session.py +++ b/mediagoblin/tools/session.py @@ -59,7 +59,7 @@ class SessionManager(object): return Session() def save_session_to_cookie(self, session, response): - if not session.is_updated: + if not session.is_updated(): return elif not session: response.delete_cookie(self.cookie_name)