From 5cc157a749d4720144f4156c1d9fbd673e1dc68d Mon Sep 17 00:00:00 2001 From: Cuidight Heach Date: Sun, 10 Mar 2013 18:03:04 +0200 Subject: Added test for unicode basic authentication --- pyramid/tests/test_authentication.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index 123e4f9f5..c9313e0c6 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1287,6 +1287,16 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): policy = self._makeOne(check) self.assertEqual(policy.authenticated_userid(request), 'chrisr') + def test_authenticated_userid_utf8(self): + import base64 + request = testing.DummyRequest() + inputs = u'm\xf6rk\xf6:m\xf6rk\xf6password' + request.headers['Authorization'] = 'Basic %s' % base64.b64encode(inputs.encode('utf-8')) + def check(username, password, request): + return [] + policy = self._makeOne(check) + self.assertEqual(policy.authenticated_userid(request), u'm\xf6rk\xf6') + def test_unauthenticated_userid_invalid_payload(self): import base64 request = testing.DummyRequest() -- cgit v1.2.3 From 3ac0b98a980a930ec9e6a6d4d364eb8af505a77c Mon Sep 17 00:00:00 2001 From: Cuidight Heach Date: Sun, 10 Mar 2013 20:36:47 +0200 Subject: Python 3 compatible string handling --- pyramid/tests/test_authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index c9313e0c6..e696a5754 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1290,12 +1290,12 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def test_authenticated_userid_utf8(self): import base64 request = testing.DummyRequest() - inputs = u'm\xf6rk\xf6:m\xf6rk\xf6password' + inputs = 'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') request.headers['Authorization'] = 'Basic %s' % base64.b64encode(inputs.encode('utf-8')) def check(username, password, request): return [] policy = self._makeOne(check) - self.assertEqual(policy.authenticated_userid(request), u'm\xf6rk\xf6') + self.assertEqual(policy.authenticated_userid(request), 'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) def test_unauthenticated_userid_invalid_payload(self): import base64 -- cgit v1.2.3 From dc8533aad4d432817b52f158804f0b6a81fab374 Mon Sep 17 00:00:00 2001 From: Cuidight Heach Date: Sun, 10 Mar 2013 20:59:00 +0200 Subject: Switched to bytes --- pyramid/tests/test_authentication.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index e696a5754..97b2b1a7c 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1290,12 +1290,12 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def test_authenticated_userid_utf8(self): import base64 request = testing.DummyRequest() - inputs = 'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') + inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') request.headers['Authorization'] = 'Basic %s' % base64.b64encode(inputs.encode('utf-8')) def check(username, password, request): return [] policy = self._makeOne(check) - self.assertEqual(policy.authenticated_userid(request), 'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) + self.assertEqual(policy.authenticated_userid(request), b'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) def test_unauthenticated_userid_invalid_payload(self): import base64 -- cgit v1.2.3 From 3a6cbcce80b5292082b2f4e2f920d2df127e2774 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 3 Oct 2013 03:32:28 -0500 Subject: modification to the unencrypted cookie to use a clearer api improved the signing to use a derived key based on a random salt, and upgraded the hash from sha1 to sha512. Finally the entire result is b64 instead of just the payload. --- pyramid/session.py | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 8 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index 3708ef879..db86ca32b 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -1,3 +1,4 @@ +import hashlib from hashlib import sha1 import base64 import binascii @@ -119,7 +120,7 @@ def UnencryptedCookieSessionFactoryConfig( cookie_max_age=None, cookie_path='/', cookie_domain=None, - cookie_secure=False, + cookie_secure=False, cookie_httponly=False, cookie_on_exception=True, signed_serialize=signed_serialize, @@ -182,7 +183,7 @@ def UnencryptedCookieSessionFactoryConfig( """ @implementer(ISession) - class UnencryptedCookieSessionFactory(dict): + class SignedCookieSession(dict): """ Dictionary-like session object """ # configuration parameters @@ -306,12 +307,146 @@ def UnencryptedCookieSessionFactoryConfig( response.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, + 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 UnencryptedCookieSessionFactory + return SignedCookieSession + +def SignedCookieSessionFactory( + secret, + hashalg='sha512', + timeout=1200, + cookie_name='session', + cookie_max_age=None, + cookie_path='/', + cookie_domain=None, + cookie_secure=False, + cookie_httponly=False, + cookie_on_exception=True, + serializer=None, + ): + """ + Configure a :term:`session factory` which will provide 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.config.Configurator` constructor, or used + as the ``session_factory`` argument of the + :meth:`pyramid.config.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. + + ``hashalg`` + The HMAC digest algorithm to use for signing. The algorithm must be + supported by the :mod:`hashlib` library. Default: ``'sha512'``. + + ``timeout`` + A number of seconds of inactivity before a session times out. + Default: 1200. + + ``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: ``True``. + + ``serializer`` + An object which can convert data between Python objects and bytestrings. + - ``loads`` should accept a bytestring and return a Python object. + - ``dumps`` should accept a Python object and return a bytestring. + The default implementation uses Python's pickle module. + """ + + if serializer is None: + serializer = _PickleSerializer() + + signer = _SignedSerializer(hashalg, serializer) + + return UnencryptedCookieSessionFactoryConfig( + secret, + timeout=timeout, + 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, + signed_serialize=signer.dumps, + signed_deserialize=signer.loads, + ) + +class _PickleSerializer(object): + def dumps(self, value): + return pickle.dumps(value, pickle.HIGHEST_PROTOCOL) + + def loads(self, value): + return pickle.loads(value) + +class _SignedSerializer(object): + def __init__(self, hashalg, serializer): + self.digestmod = lambda: hashlib.new(hashalg) + self.digest_size = self.digestmod().digest_size + self.salt_size = 8 + self.serializer = serializer + + def derive_key(self, secret, salt): + return hmac.new(secret, salt, self.digestmod).digest() + + def dumps(self, appstruct, secret): + salt = os.urandom(self.salt_size) + derived_secret = self.derive_key(secret, salt) + cstruct = self.serializer.dumps(appstruct) + sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() + return native_(base64.b64encode(cstruct + salt + sig)) + + def loads(self, bstruct, secret): + try: + fstruct = base64.b64decode(bytes_(bstruct)) + except (binascii.Error, TypeError) as e: + raise ValueError('Badly formed base64 data: %s' % e) + + cstruct_size = len(fstruct) - self.salt_size - self.digest_size + if cstruct_size < 0: + raise ValueError('Input is too short.') + + cstruct = fstruct[:cstruct_size] + salt = fstruct[cstruct_size:self.salt_size] + expected_sig = fstruct[:-self.digest_size] + + derived_secret = self.derive_key(secret, salt) + sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() + if strings_differ(sig, expected_sig): + raise ValueError('Invalid signature') + + return self.serializer.loads(cstruct) -- cgit v1.2.3 From 4fade654a42b88ea1f042af974f76b97d326c455 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 5 Oct 2013 03:48:52 -0500 Subject: introduce SignedCookieSessionFactory - Break apart UnencryptedCookieSessionFactoryConfig into a BaseCookieSessionFactory. - Add support for reissue_time in the base. Set the unencrypted class to use reissue_time=0 for bw-compat. - Add SignedCookieSessionFactory which wraps the base in a serializer that uses signing via a sha512+hmac with a secret derived using an 8-byte random salt. --- pyramid/session.py | 400 ++++++++++++++++++++++++++++-------------- pyramid/tests/test_session.py | 290 ++++++++++++++++++++++++------ 2 files changed, 508 insertions(+), 182 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index db86ca32b..e6635ca1b 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -21,20 +21,26 @@ from pyramid.interfaces import ISession from pyramid.util import strings_differ def manage_accessed(wrapped): - """ Decorator which causes a cookie to be set when a wrapped - method is called""" + """ Decorator which causes a cookie to be renewed when an accessor + 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) + session.accessed = now = int(time.time()) + if now - session.renewed > session._reissue_time: + session.changed() return wrapped(session, *arg, **kw) accessed.__doc__ = wrapped.__doc__ return accessed +def manage_changed(wrapped): + """ Decorator which causes a cookie to be set when a setter method + is called.""" + def changed(session, *arg, **kw): + session.accessed = int(time.time()) + session.changed() + return wrapped(session, *arg, **kw) + changed.__doc__ = wrapped.__doc__ + return changed + def signed_serialize(data, secret): """ Serialize any pickleable structure (``data``) and sign it using the ``secret`` (must be a string). Return the @@ -67,13 +73,13 @@ def signed_deserialize(serialized, secret, hmac=hmac): """ # hmac parameterized only for unit tests try: - input_sig, pickled = (serialized[:40], + input_sig, pickled = (bytes_(serialized[:40]), base64.b64decode(bytes_(serialized[40:]))) except (binascii.Error, TypeError) as e: # Badly formed data can make base64 die raise ValueError('Badly formed base64 data: %s' % e) - sig = hmac.new(bytes_(secret), pickled, sha1).hexdigest() + sig = bytes_(hmac.new(bytes_(secret), pickled, sha1).hexdigest()) # Avoid timing attacks (see # http://seb.dbzteam.org/crypto/python-oauth-timing-hmac.pdf) @@ -113,89 +119,108 @@ def check_csrf_token(request, return False return True -def UnencryptedCookieSessionFactoryConfig( - secret, - timeout=1200, +def BaseCookieSessionFactory( + serializer, cookie_name='session', - cookie_max_age=None, - cookie_path='/', - cookie_domain=None, - cookie_secure=False, - cookie_httponly=False, - cookie_on_exception=True, - signed_serialize=signed_serialize, - signed_deserialize=signed_deserialize, + max_age=None, + path='/', + domain=None, + secure=False, + httponly=False, + timeout=1200, + reissue_time=0, + set_on_exception=True, ): """ - Configure a :term:`session factory` which will provide unencrypted - (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.config.Configurator` constructor, or used + Configure a :term:`session factory` which will provide 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.config.Configurator` constructor, or used as the ``session_factory`` argument of the - :meth:`pyramid.config.Configurator.set_session_factory` - method. + :meth:`pyramid.config.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: + .. warning: - ``secret`` - A string which is used to sign the cookie. + This class provides no protection from tampering and is only intended + to be used by framework authors to create their own cookie-based + session factories. - ``timeout`` - A number of seconds of inactivity before a session times out. + Parameters: + + ``serializer`` + An object with 2 methods, ``loads`` and ``dumps``, which will be used + to perform serialization and deserialization. + - ``dumps(value)`` should accept a Python object and return a + bytestring which can later be deserialized with ``loads``. + - ``loads(value)`` should expect to receive a bytestring, generated by + ``dumps`` and return a Python object. ``cookie_name`` - The name of the cookie used for sessioning. + The name of the cookie used for sessioning. Default: ``'session'``. - ``cookie_max_age`` + ``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. + ``path`` + The path used for the session cookie. Default: ``'/'``. - ``cookie_domain`` + ``domain`` The domain used for the session cookie. Default: ``None`` (no domain). - ``cookie_secure`` - The 'secure' flag of the session cookie. + ``secure`` + The 'secure' flag of the session cookie. Default: ``False``. - ``cookie_httponly`` - The 'httpOnly' flag of the session cookie. + ``httponly`` + Hide the cookie from Javascript by setting the 'HttpOnly' flag of the + session cookie. Default: ``False``. - ``cookie_on_exception`` + ``timeout`` + A number of seconds of inactivity before a session times out. If + ``None`` then the cookie never expires. Default: 1200. + + ``reissue_time`` + The number of seconds that must pass before the cookie is automatically + reissued as the result of a request which accesses the session. The + duration is measured as the number of seconds since the last session + cookie was issued and 'now'. If this value is ``0``, a new cookie + will be reissued on every request accesses the session. If ``None`` + then the cookie's lifetime will never be extended. + + A good rule of thumb: if you want auto-expired cookies based on + inactivity: set the ``timeout`` value to 1200 (20 mins) and set the + ``reissue_time`` value to perhaps a tenth of the ``timeout`` value + (120 or 2 mins). It's nonsensical to set the ``timeout`` value lower + than the ``reissue_time`` value, as the ticket will never be reissued. + However, such a configuration is not explicitly prevented. + + Default: ``0``. + + ``set_on_exception`` If ``True``, set a session cookie even if an exception occurs - while rendering a view. - - ``signed_serialize`` - A callable which takes more or less arbitrary Python data structure and - a secret and returns a signed serialization in bytes. - Default: ``signed_serialize`` (using pickle). + while rendering a view. Default: ``True``. - ``signed_deserialize`` - A callable which takes a signed and serialized data structure in bytes - and a secret and returns the original data structure if the signature - is valid. Default: ``signed_deserialize`` (using pickle). + .. versionadded: 1.5a3 """ @implementer(ISession) - class SignedCookieSession(dict): + class CookieSession(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 + _cookie_max_age = max_age + _cookie_path = path + _cookie_domain = domain + _cookie_secure = secure + _cookie_httponly = httponly + _cookie_on_exception = set_on_exception _timeout = timeout + _reissue_time = reissue_time # dirty flag _dirty = False @@ -203,33 +228,40 @@ def UnencryptedCookieSessionFactoryConfig( def __init__(self, request): self.request = request now = time.time() - created = accessed = now + created = renewed = now new = True value = None state = {} cookieval = request.cookies.get(self._cookie_name) if cookieval is not None: try: - value = signed_deserialize(cookieval, self._secret) + value = serializer.loads(bytes_(cookieval)) except ValueError: value = None if value is not None: - accessed, created, state = value - new = False - if now - accessed > self._timeout: + try: + renewed, created, state = value + new = False + if now - renewed > self._timeout: + state = {} + except TypeError: state = {} self.created = created - self.accessed = accessed + self.accessed = renewed + self.renewed = renewed 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 + if not self._dirty: + self._dirty = True + def set_cookie_callback(request, response): + self._set_cookie(response) + self.request = None # explicitly break cycle for gc + self.request.add_response_callback(set_cookie_callback) def invalidate(self): self.clear() # XXX probably needs to unset cookie @@ -251,22 +283,22 @@ def UnencryptedCookieSessionFactoryConfig( has_key = manage_accessed(dict.has_key) # modifying dictionary methods - clear = manage_accessed(dict.clear) - update = manage_accessed(dict.update) - setdefault = manage_accessed(dict.setdefault) - pop = manage_accessed(dict.pop) - popitem = manage_accessed(dict.popitem) - __setitem__ = manage_accessed(dict.__setitem__) - __delitem__ = manage_accessed(dict.__delitem__) + clear = manage_changed(dict.clear) + update = manage_changed(dict.update) + setdefault = manage_changed(dict.setdefault) + pop = manage_changed(dict.pop) + popitem = manage_changed(dict.popitem) + __setitem__ = manage_changed(dict.__setitem__) + __delitem__ = manage_changed(dict.__delitem__) # flash API methods - @manage_accessed + @manage_changed def flash(self, msg, queue='', allow_duplicate=True): storage = self.setdefault('_f_' + queue, []) if allow_duplicate or (msg not in storage): storage.append(msg) - @manage_accessed + @manage_changed def pop_flash(self, queue=''): storage = self.pop('_f_' + queue, []) return storage @@ -277,7 +309,7 @@ def UnencryptedCookieSessionFactoryConfig( return storage # CSRF API methods - @manage_accessed + @manage_changed def new_csrf_token(self): token = text_(binascii.hexlify(os.urandom(20))) self['_csrft_'] = token @@ -296,9 +328,9 @@ def UnencryptedCookieSessionFactoryConfig( exception = getattr(self.request, 'exception', None) if exception is not None: # dont set a cookie during exceptions return False - cookieval = signed_serialize( - (self.accessed, self.created, dict(self)), self._secret - ) + cookieval = native_(serializer.dumps( + (self.accessed, self.created, dict(self)) + )) if len(cookieval) > 4064: raise ValueError( 'Cookie value is too long to store (%s bytes)' % @@ -315,11 +347,10 @@ def UnencryptedCookieSessionFactoryConfig( ) return True - return SignedCookieSession + return CookieSession -def SignedCookieSessionFactory( +def UnencryptedCookieSessionFactoryConfig( secret, - hashalg='sha512', timeout=1200, cookie_name='session', cookie_max_age=None, @@ -328,6 +359,97 @@ def SignedCookieSessionFactory( cookie_secure=False, cookie_httponly=False, cookie_on_exception=True, + signed_serialize=signed_serialize, + signed_deserialize=signed_deserialize, + ): + """ + Configure a :term:`session factory` which will provide unencrypted + (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.config.Configurator` constructor, or used + as the ``session_factory`` argument of the + :meth:`pyramid.config.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. + + ``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. + + ``cookie_domain`` + The domain used for the session cookie. Default: ``None`` (no domain). + + ``cookie_secure`` + The 'secure' flag of the session cookie. + + ``cookie_httponly`` + The 'httpOnly' flag of the session cookie. + + ``cookie_on_exception`` + If ``True``, set a session cookie even if an exception occurs + while rendering a view. + + ``signed_serialize`` + A callable which takes more or less arbitrary Python data structure and + a secret and returns a signed serialization in bytes. + Default: ``signed_serialize`` (using pickle). + + ``signed_deserialize`` + A callable which takes a signed and serialized data structure in bytes + and a secret and returns the original data structure if the signature + is valid. Default: ``signed_deserialize`` (using pickle). + """ + + class _Serializer(object): + def dumps(self, appstruct): + return signed_serialize(appstruct, secret) + + def loads(self, bstruct): + return signed_deserialize(bstruct, secret) + + return BaseCookieSessionFactory( + _Serializer(), + cookie_name=cookie_name, + max_age=cookie_max_age, + path=cookie_path, + domain=cookie_domain, + secure=cookie_secure, + httponly=cookie_httponly, + timeout=timeout, + reissue_time=0, # to keep session.accessed == session.renewed + set_on_exception=cookie_on_exception, + ) + +def SignedCookieSessionFactory( + secret, + cookie_name='session', + max_age=None, + path='/', + domain=None, + secure=False, + httponly=False, + set_on_exception=True, + timeout=1200, + reissue_time=0, + hashalg='sha512', serializer=None, ): """ @@ -353,86 +475,106 @@ def SignedCookieSessionFactory( The HMAC digest algorithm to use for signing. The algorithm must be supported by the :mod:`hashlib` library. Default: ``'sha512'``. - ``timeout`` - A number of seconds of inactivity before a session times out. - Default: 1200. - ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. - ``cookie_max_age`` + ``max_age`` The maximum age of the cookie used for sessioning (in seconds). Default: ``None`` (browser scope). - ``cookie_path`` + ``path`` The path used for the session cookie. Default: ``'/'``. - ``cookie_domain`` + ``domain`` The domain used for the session cookie. Default: ``None`` (no domain). - ``cookie_secure`` + ``secure`` The 'secure' flag of the session cookie. Default: ``False``. - ``cookie_httponly`` - The 'httpOnly' flag of the session cookie. Default: ``False``. + ``httponly`` + Hide the cookie from Javascript by setting the 'HttpOnly' flag of the + session cookie. Default: ``False``. - ``cookie_on_exception`` + ``timeout`` + A number of seconds of inactivity before a session times out. If + ``None`` then the cookie never expires. Default: 1200. + + ``reissue_time`` + The number of seconds that must pass before the cookie is automatically + reissued as the result of a request which accesses the session. The + duration is measured as the number of seconds since the last session + cookie was issued and 'now'. If this value is ``0``, a new cookie + will be reissued on every request accesses the session. If ``None`` + then the cookie's lifetime will never be extended. + + A good rule of thumb: if you want auto-expired cookies based on + inactivity: set the ``timeout`` value to 1200 (20 mins) and set the + ``reissue_time`` value to perhaps a tenth of the ``timeout`` value + (120 or 2 mins). It's nonsensical to set the ``timeout`` value lower + than the ``reissue_time`` value, as the ticket will never be reissued. + However, such a configuration is not explicitly prevented. + + Default: ``0``. + + ``set_on_exception`` If ``True``, set a session cookie even if an exception occurs while rendering a view. Default: ``True``. ``serializer`` - An object which can convert data between Python objects and bytestrings. - - ``loads`` should accept a bytestring and return a Python object. - - ``dumps`` should accept a Python object and return a bytestring. - The default implementation uses Python's pickle module. + An object with 2 methods, ``loads`` and ``dumps``, which will be used + to perform serialization and deserialization. The value generated from + serialization will be cryptographically signed to prevent tampering. + A ``ValueError`` should be raised if deserialization fails. + + .. versionadded: 1.5a3 """ if serializer is None: serializer = _PickleSerializer() - signer = _SignedSerializer(hashalg, serializer) + signed_serializer = _SignedSerializer(secret, hashalg, serializer) - return UnencryptedCookieSessionFactoryConfig( - secret, - timeout=timeout, + return BaseCookieSessionFactory( + signed_serializer, 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, - signed_serialize=signer.dumps, - signed_deserialize=signer.loads, + max_age=max_age, + path=path, + domain=domain, + secure=secure, + httponly=httponly, + timeout=timeout, + reissue_time=reissue_time, + set_on_exception=set_on_exception, ) class _PickleSerializer(object): - def dumps(self, value): - return pickle.dumps(value, pickle.HIGHEST_PROTOCOL) + def dumps(self, appstruct): + return pickle.dumps(appstruct, pickle.HIGHEST_PROTOCOL) - def loads(self, value): - return pickle.loads(value) + def loads(self, bstruct): + return pickle.loads(bstruct) class _SignedSerializer(object): - def __init__(self, hashalg, serializer): + def __init__(self, secret, hashalg, serializer): + self.secret = secret self.digestmod = lambda: hashlib.new(hashalg) self.digest_size = self.digestmod().digest_size self.salt_size = 8 self.serializer = serializer - def derive_key(self, secret, salt): - return hmac.new(secret, salt, self.digestmod).digest() + def derive_key(self, salt): + return hmac.new(self.secret, salt, self.digestmod).digest() - def dumps(self, appstruct, secret): + def dumps(self, appstruct): salt = os.urandom(self.salt_size) - derived_secret = self.derive_key(secret, salt) + derived_secret = self.derive_key(salt) cstruct = self.serializer.dumps(appstruct) sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() - return native_(base64.b64encode(cstruct + salt + sig)) + return base64.b64encode(cstruct + salt + sig) - def loads(self, bstruct, secret): + def loads(self, bstruct): try: - fstruct = base64.b64decode(bytes_(bstruct)) + fstruct = base64.b64decode(bstruct) except (binascii.Error, TypeError) as e: raise ValueError('Badly formed base64 data: %s' % e) @@ -441,10 +583,10 @@ class _SignedSerializer(object): raise ValueError('Input is too short.') cstruct = fstruct[:cstruct_size] - salt = fstruct[cstruct_size:self.salt_size] - expected_sig = fstruct[:-self.digest_size] + salt = fstruct[cstruct_size:cstruct_size + self.salt_size] + expected_sig = fstruct[-self.digest_size:] - derived_secret = self.derive_key(secret, salt) + derived_secret = self.derive_key(salt) sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() if strings_differ(sig, expected_sig): raise ValueError('Invalid signature') diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 35e2b5c27..0e1ed78a6 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -1,10 +1,8 @@ +import json import unittest from pyramid import testing -class TestUnencryptedCookieSession(unittest.TestCase): - def _makeOne(self, request, **kw): - from pyramid.session import UnencryptedCookieSessionFactoryConfig - return UnencryptedCookieSessionFactoryConfig('secret', **kw)(request) +class SharedCookieSessionTests(object): def test_ctor_no_cookie(self): request = testing.DummyRequest() @@ -18,36 +16,47 @@ class TestUnencryptedCookieSession(unittest.TestCase): session = self._makeOne(request) verifyObject(ISession, session) - def _serialize(self, accessed, state, secret='secret'): - from pyramid.session import signed_serialize - return signed_serialize((accessed, accessed, state), secret) - def test_ctor_with_cookie_still_valid(self): import time request = testing.DummyRequest() - cookieval = self._serialize(time.time(), {'state':1}) + cookieval = self._serialize((time.time(), 0, {'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}) + cookieval = self._serialize((0, 0, {'state': 1})) request.cookies['session'] = cookieval session = self._makeOne(request) self.assertEqual(dict(session), {}) - def test_ctor_with_bad_cookie(self): + def test_ctor_with_bad_cookie_cannot_deserialize(self): + request = testing.DummyRequest() + request.cookies['session'] = 'abc' + session = self._makeOne(request) + self.assertEqual(dict(session), {}) + + def test_ctor_with_bad_cookie_not_tuple(self): request = testing.DummyRequest() - cookieval = 'abc' + cookieval = self._serialize('abc') request.cookies['session'] = cookieval session = self._makeOne(request) self.assertEqual(dict(session), {}) + def test_timeout(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time() - 5, 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request, timeout=1) + self.assertEqual(dict(session), {}) + def test_changed(self): request = testing.DummyRequest() session = self._makeOne(request) self.assertEqual(session.changed(), None) + self.assertTrue(session._dirty) def test_invalidate(self): request = testing.DummyRequest() @@ -56,6 +65,15 @@ class TestUnencryptedCookieSession(unittest.TestCase): self.assertEqual(session.invalidate(), None) self.assertFalse('a' in session) + def test_reissue_triggered(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time() - 2, 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request) + self.assertEqual(session['state'], 1) + self.assertTrue(session._dirty) + def test__set_cookie_on_exception(self): request = testing.DummyRequest() request.exception = True @@ -95,16 +113,16 @@ class TestUnencryptedCookieSession(unittest.TestCase): 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, + cookie_name='abc', + path='/foo', + domain='localhost', + secure=True, + httponly=True, ) session['abc'] = 'x' response = Response() self.assertEqual(session._set_cookie(response), True) - cookieval= response.headerlist[-1][1] + cookieval = response.headerlist[-1][1] val, domain, path, secure, httponly = [x.strip() for x in cookieval.split(';')] self.assertTrue(val.startswith('abc=')) @@ -205,6 +223,110 @@ class TestUnencryptedCookieSession(unittest.TestCase): self.assertTrue(token) self.assertTrue('_csrft_' in session) + def test_no_set_cookie_with_exception(self): + import webob + request = testing.DummyRequest() + request.exception = True + session = self._makeOne(request, set_on_exception=False) + session['a'] = 1 + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = webob.Response() + result = callbacks[0](request, response) + self.assertEqual(result, None) + self.assertFalse('Set-Cookie' in dict(response.headerlist)) + + def test_set_cookie_with_exception(self): + import webob + request = testing.DummyRequest() + request.exception = True + session = self._makeOne(request) + session['a'] = 1 + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = webob.Response() + result = callbacks[0](request, response) + self.assertEqual(result, None) + self.assertTrue('Set-Cookie' in dict(response.headerlist)) + + def test_cookie_is_set(self): + import webob + request = testing.DummyRequest() + session = self._makeOne(request) + session['a'] = 1 + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = webob.Response() + result = callbacks[0](request, response) + self.assertEqual(result, None) + self.assertTrue('Set-Cookie' in dict(response.headerlist)) + +class TestBaseCookieSession(SharedCookieSessionTests, unittest.TestCase): + def _makeOne(self, request, **kw): + from pyramid.session import BaseCookieSessionFactory + return BaseCookieSessionFactory(DummySerializer(), **kw)(request) + + def _serialize(self, value): + return json.dumps(value) + + def test_reissue_not_triggered(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request, reissue_time=1) + self.assertEqual(session['state'], 1) + self.assertFalse(session._dirty) + +class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): + def _makeOne(self, request, **kw): + from pyramid.session import SignedCookieSessionFactory + return SignedCookieSessionFactory('secret', **kw)(request) + + def _serialize(self, value): + from pyramid.session import _SignedSerializer, _PickleSerializer + serializer = _PickleSerializer() + serializer = _SignedSerializer('secret', 'sha512', serializer) + return serializer.dumps(value) + + def test_reissue_not_triggered(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request, reissue_time=1) + self.assertEqual(session['state'], 1) + self.assertFalse(session._dirty) + + def test_custom_serializer(self): + import time + from pyramid.session import _SignedSerializer + serializer = DummySerializer() + signer = _SignedSerializer('secret', 'sha512', serializer=serializer) + cookieval = signer.dumps((time.time(), 0, {'state': 1})) + request = testing.DummyRequest() + request.cookies['session'] = cookieval + session = self._makeOne(request, serializer=serializer) + self.assertEqual(session['state'], 1) + +class TestUnencryptedCookieSession(SharedCookieSessionTests, unittest.TestCase): + def _makeOne(self, request, **kw): + from pyramid.session import UnencryptedCookieSessionFactoryConfig + self._rename_cookie_var(kw, 'path', 'cookie_path') + self._rename_cookie_var(kw, 'domain', 'cookie_domain') + self._rename_cookie_var(kw, 'secure', 'cookie_secure') + self._rename_cookie_var(kw, 'httponly', 'cookie_httponly') + self._rename_cookie_var(kw, 'set_on_exception', 'cookie_on_exception') + return UnencryptedCookieSessionFactoryConfig('secret', **kw)(request) + + def _rename_cookie_var(self, kw, src, dest): + if src in kw: + kw.setdefault(dest, kw.pop(src)) + + def _serialize(self, value): + from pyramid.session import signed_serialize + return signed_serialize(value, 'secret') + def test_serialize_option(self): from pyramid.response import Response secret = 'secret' @@ -255,54 +377,48 @@ class Test_manage_accessed(unittest.TestCase): def test_accessed_set(self): request = testing.DummyRequest() session = DummySessionFactory(request) - session.accessed = None + session.renewed = 0 wrapper = self._makeOne(session.__class__.get) wrapper(session, 'a') self.assertNotEqual(session.accessed, None) - - def test_already_dirty(self): + self.assertTrue(session._dirty) + + def test_accessed_without_renew(self): + import time request = testing.DummyRequest() session = DummySessionFactory(request) - session._dirty = True - session['a'] = 1 + session._reissue_time = 5 + session.renewed = time.time() 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) + wrapper(session, 'a') + self.assertNotEqual(session.accessed, None) + self.assertFalse(session._dirty) - def test_with_exception(self): - import webob + def test_already_dirty(self): request = testing.DummyRequest() - request.exception = True session = DummySessionFactory(request) + session.renewed = 0 + 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), 1) - response = webob.Response() - result = callbacks[0](request, response) - self.assertEqual(result, None) - self.assertFalse('Set-Cookie' in dict(response.headerlist)) + self.assertEqual(len(callbacks), 0) - def test_cookie_is_set(self): +class Test_manage_changed(unittest.TestCase): + def _makeOne(self, wrapped): + from pyramid.session import manage_changed + return manage_changed(wrapped) + + def test_it(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) + wrapper = self._makeOne(session.__class__.__setitem__) + wrapper(session, 'a', 1) + self.assertNotEqual(session.accessed, None) + self.assertTrue(session._dirty) def serialize(data, secret): import hmac @@ -354,7 +470,67 @@ class Test_signed_deserialize(unittest.TestCase): def test_it_bad_encoding(self): serialized = 'bad' + serialize('123', 'secret') self.assertRaises(ValueError, self._callFUT, serialized, 'secret') - + +class TestSignedSerializer(unittest.TestCase): + def _makeOne(self, secret='secret', hashalg='sha512'): + from pyramid.session import _SignedSerializer + serializer = DummySerializer() + return _SignedSerializer(secret, hashalg, serializer) + + def test_it_same_serializer(self): + serializer = self._makeOne() + appstruct = {'state': 1} + cstruct = serializer.dumps(appstruct) + result = serializer.loads(cstruct) + self.assertEqual(result, appstruct) + + def test_it_different_serializers(self): + serializer1 = self._makeOne() + appstruct = {'state': 1} + cstruct = serializer1.dumps(appstruct) + + serializer2 = self._makeOne() + result = serializer2.loads(cstruct) + self.assertEqual(result, appstruct) + + def test_invalid_signature_with_different_secret(self): + serializer1 = self._makeOne('secret1') + appstruct = {'state': 1} + cstruct = serializer1.dumps(appstruct) + + serializer2 = self._makeOne('secret2') + try: + serializer2.loads(cstruct) + except ValueError as exc: + self.assertTrue('Invalid signature' in exc.args[0]) + else: # pragma: no cover + self.fail() + + def test_invalid_signature_after_tamper(self): + import base64 + serializer = self._makeOne() + appstruct = {'state': 1} + cstruct = serializer.dumps(appstruct) + actual_val = base64.b64decode(cstruct) + test_val = base64.b64encode(actual_val[1:]) + try: + serializer.loads(test_val) + except ValueError as exc: + self.assertTrue('Invalid signature' in exc.args[0]) + else: # pragma: no cover + self.fail() + + def test_invalid_data_size(self): + import base64 + serializer = self._makeOne() + num_bytes = serializer.digest_size + serializer.salt_size - 1 + try: + serializer.loads(base64.b64encode(b' ' * num_bytes)) + except ValueError as exc: + self.assertTrue('Input is too short' in exc.args[0]) + else: # pragma: no cover + self.fail() + class Test_check_csrf_token(unittest.TestCase): def _callFUT(self, *args, **kwargs): from ..session import check_csrf_token @@ -390,6 +566,13 @@ class Test_check_csrf_token(unittest.TestCase): result = self._callFUT(request, 'csrf_token', raises=False) self.assertEqual(result, False) +class DummySerializer(object): + def dumps(self, value): + return json.dumps(value).encode('utf-8') + + def loads(self, value): + return json.loads(value.decode('utf-8')) + class DummySessionFactory(dict): _dirty = False _cookie_name = 'session' @@ -399,13 +582,14 @@ class DummySessionFactory(dict): _cookie_secure = False _cookie_httponly = False _timeout = 1200 - _secret = 'secret' + _reissue_time = 0 + def __init__(self, request): self.request = request dict.__init__(self, {}) - def _set_cookie(self, response): - self.response = response + def changed(self): + self._dirty = True class DummyResponse(object): def __init__(self): -- cgit v1.2.3 From 61e938dbbb75849f70e7d426b717bdf03d9f3ff4 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 5 Oct 2013 03:59:07 -0500 Subject: fix py3 --- pyramid/session.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index e6635ca1b..3c493a561 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -522,9 +522,11 @@ def SignedCookieSessionFactory( ``serializer`` An object with 2 methods, ``loads`` and ``dumps``, which will be used - to perform serialization and deserialization. The value generated from - serialization will be cryptographically signed to prevent tampering. - A ``ValueError`` should be raised if deserialization fails. + to perform serialization and deserialization. + - ``dumps(value)`` should accept a Python object and return a + bytestring which can later be deserialized with ``loads``. + - ``loads(value)`` should expect to receive a bytestring, generated by + ``dumps`` and return a Python object. .. versionadded: 1.5a3 """ @@ -563,7 +565,7 @@ class _SignedSerializer(object): self.serializer = serializer def derive_key(self, salt): - return hmac.new(self.secret, salt, self.digestmod).digest() + return hmac.new(bytes_(self.secret), salt, self.digestmod).digest() def dumps(self, appstruct): salt = os.urandom(self.salt_size) -- cgit v1.2.3 From 0905d2015e35e827c3fdb2135695710b80d549a5 Mon Sep 17 00:00:00 2001 From: "Karl O. Pinc" Date: Tue, 8 Oct 2013 11:50:11 -0500 Subject: Subclass HTTPBadCSRFToken from HTTPBadRequest and have request.session.check_csrf_token use the new exception. This supports a more fine-grained exception trapping. --- docs/api/httpexceptions.rst | 19 ++++++++++++++++--- pyramid/httpexceptions.py | 40 ++++++++++++++++++++++++++++++++++++---- pyramid/session.py | 6 +++--- pyramid/tests/test_session.py | 5 +++-- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/docs/api/httpexceptions.rst b/docs/api/httpexceptions.rst index 6a08d1048..0fdd0f0e9 100644 --- a/docs/api/httpexceptions.rst +++ b/docs/api/httpexceptions.rst @@ -7,9 +7,12 @@ .. attribute:: status_map - A mapping of integer status code to exception class (eg. the - integer "401" maps to - :class:`pyramid.httpexceptions.HTTPUnauthorized`). + A mapping of integer status code to HTTP exception class (eg. the integer + "401" maps to :class:`pyramid.httpexceptions.HTTPUnauthorized`). All + mapped exception classes are children of :class:`pyramid.httpexceptions`, + i.e. the :ref:`pyramid_specific_http_exceptions` such as + :class:`pyramid.httpexceptions.HTTPBadRequest.BadCSRFToken` are not + mapped. .. autofunction:: exception_response @@ -106,3 +109,13 @@ .. autoclass:: HTTPVersionNotSupported .. autoclass:: HTTPInsufficientStorage + + +.. _pyramid_specific_http_exceptions: + +Pyramid-specific HTTP Exceptions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each Pyramid-specific HTTP exception has the status code of it's parent. + + .. autoclass:: HTTPBadCSRFToken diff --git a/pyramid/httpexceptions.py b/pyramid/httpexceptions.py index fff17b2df..21d862a6b 100644 --- a/pyramid/httpexceptions.py +++ b/pyramid/httpexceptions.py @@ -2,10 +2,13 @@ HTTP Exceptions --------------- -This module contains Pyramid HTTP exception classes. Each class relates to a -single HTTP status code. Each class is a subclass of the -:class:`~HTTPException`. Each exception class is also a :term:`response` -object. +This module contains Pyramid HTTP exception classes. Each class is a subclass +of the :class:`~HTTPException`. Each class relates to a single HTTP status +code, although the reverse is not true. There are +:ref:`pyramid_specific_http_exceptions` which are sub-classes of the +:rfc:`2608` HTTP status codes. Each of these Pyramid-specific exceptions have +the status code of it's parent. Each exception class is also a +:term:`response` object. Each exception class has a status code according to :rfc:`2068`: codes with 100-300 are not really errors; 400s are client errors, @@ -32,6 +35,9 @@ Exception HTTPError HTTPClientError * 400 - HTTPBadRequest + + * 400 - HTTPBadCSRFToken + * 401 - HTTPUnauthorized * 402 - HTTPPaymentRequired * 403 - HTTPForbidden @@ -565,8 +571,34 @@ class HTTPClientError(HTTPError): 'it is either malformed or otherwise incorrect.') class HTTPBadRequest(HTTPClientError): + """ + subclass of :class:`~HTTPClientError` + + base class for Pyramid-specific validity checks of the client's request + + This class and it's sub-classes result in a '400 Bad Request' HTTP status, + although it's sub-classes specialize the 'Bad Request' text. + """ pass +class HTTPBadCSRFToken(HTTPClientError): + """ + subclass of :class:`~HTTPBadRequest` + + This indicates the request has failed cross-site request forgery token + validation. + + title: Bad CSRF Token + """ + title = 'Bad CSRF Token' + explanation = ( + 'Access is denied. This server can not verify that your cross-site ' + 'request forgery token belongs to your login session. Either you ' + 'supplied the wrong cross-site request forgery token or your session ' + 'no longer exists. This may be due to session timeout or because ' + 'browser is not supplying the credentials required, as can happen ' + 'when the browser has cookies turned off.') + class HTTPUnauthorized(HTTPClientError): """ subclass of :class:`~HTTPClientError` diff --git a/pyramid/session.py b/pyramid/session.py index 3708ef879..72b69117c 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -15,7 +15,7 @@ from pyramid.compat import ( native_, ) -from pyramid.httpexceptions import HTTPBadRequest +from pyramid.httpexceptions import HTTPBadCSRFToken from pyramid.interfaces import ISession from pyramid.util import strings_differ @@ -95,7 +95,7 @@ def check_csrf_token(request, If the value supplied by param or by header doesn't match the value supplied by ``request.session.get_csrf_token()``, and ``raises`` is ``True``, this function will raise an - :exc:`pyramid.httpexceptions.HTTPBadRequest` exception. + :exc:`pyramid.httpexceptions.HTTPBadCSRFToken` exception. If the check does succeed and ``raises`` is ``False``, this function will return ``False``. If the CSRF check is successful, this function will return ``True`` unconditionally. @@ -108,7 +108,7 @@ def check_csrf_token(request, supplied_token = request.params.get(token, request.headers.get(header)) if supplied_token != request.session.get_csrf_token(): if raises: - raise HTTPBadRequest('incorrect CSRF token') + raise HTTPBadCSRFToken('check_csrf_token(): Invalid token') return False return True diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 35e2b5c27..a928af43e 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -381,9 +381,10 @@ class Test_check_csrf_token(unittest.TestCase): self.assertEqual(self._callFUT(request), True) def test_failure_raises(self): - from pyramid.httpexceptions import HTTPBadRequest + from pyramid.httpexceptions import HTTPBadCSRFToken request = testing.DummyRequest() - self.assertRaises(HTTPBadRequest, self._callFUT, request, 'csrf_token') + self.assertRaises(HTTPBadCSRFToken, self._callFUT, request, + 'csrf_token') def test_failure_no_raises(self): request = testing.DummyRequest() -- cgit v1.2.3 From cd218d2934c87260bbb10620e3b419b275fe6244 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 14 Oct 2013 16:05:50 +0200 Subject: make these tests pass on python 3.2+ --- docs/tutorials/wiki/src/tests/tutorial/tests.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/tutorials/wiki/src/tests/tutorial/tests.py b/docs/tutorials/wiki/src/tests/tutorial/tests.py index c435a4519..5add04c20 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/tests.py +++ b/docs/tutorials/wiki/src/tests/tutorial/tests.py @@ -158,11 +158,11 @@ class FunctionalTests(unittest.TestCase): def test_FrontPage(self): res = self.testapp.get('/FrontPage', status=200) - self.assertTrue('FrontPage' in res.body) + self.assertTrue(b'FrontPage' in res.body) def test_unexisting_page(self): res = self.testapp.get('/SomePage', status=404) - self.assertTrue('Not Found' in res.body) + self.assertTrue(b'Not Found' in res.body) def test_successful_log_in(self): res = self.testapp.get( self.viewer_login, status=302) @@ -170,48 +170,48 @@ class FunctionalTests(unittest.TestCase): def test_failed_log_in(self): res = self.testapp.get( self.viewer_wrong_login, status=200) - self.assertTrue('login' in res.body) + self.assertTrue(b'login' in res.body) def test_logout_link_present_when_logged_in(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage', status=200) - self.assertTrue('Logout' in res.body) + self.assertTrue(b'Logout' in res.body) def test_logout_link_not_present_after_logged_out(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage', status=200) res = self.testapp.get('/logout', status=302) - self.assertTrue('Logout' not in res.body) + self.assertTrue(b'Logout' not in res.body) def test_anonymous_user_cannot_edit(self): res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue('Login' in res.body) + self.assertTrue(b'Login' in res.body) def test_anonymous_user_cannot_add(self): res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue('Login' in res.body) + self.assertTrue(b'Login' in res.body) def test_viewer_user_cannot_edit(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue('Login' in res.body) + self.assertTrue(b'Login' in res.body) def test_viewer_user_cannot_add(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue('Login' in res.body) + self.assertTrue(b'Login' in res.body) def test_editors_member_user_can_edit(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue('Editing' in res.body) + self.assertTrue(b'Editing' in res.body) def test_editors_member_user_can_add(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue('Editing' in res.body) + self.assertTrue(b'Editing' in res.body) def test_editors_member_user_can_view(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/FrontPage', status=200) - self.assertTrue('FrontPage' in res.body) + self.assertTrue(b'FrontPage' in res.body) -- cgit v1.2.3 From f23f38db37e8e323512424e5715a40dc2dce9ab8 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 14 Oct 2013 16:08:02 +0200 Subject: Revert "make these tests pass on python 3.2+" This reverts commit cd218d2934c87260bbb10620e3b419b275fe6244. --- docs/tutorials/wiki/src/tests/tutorial/tests.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/tutorials/wiki/src/tests/tutorial/tests.py b/docs/tutorials/wiki/src/tests/tutorial/tests.py index 5add04c20..c435a4519 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/tests.py +++ b/docs/tutorials/wiki/src/tests/tutorial/tests.py @@ -158,11 +158,11 @@ class FunctionalTests(unittest.TestCase): def test_FrontPage(self): res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) + self.assertTrue('FrontPage' in res.body) def test_unexisting_page(self): res = self.testapp.get('/SomePage', status=404) - self.assertTrue(b'Not Found' in res.body) + self.assertTrue('Not Found' in res.body) def test_successful_log_in(self): res = self.testapp.get( self.viewer_login, status=302) @@ -170,48 +170,48 @@ class FunctionalTests(unittest.TestCase): def test_failed_log_in(self): res = self.testapp.get( self.viewer_wrong_login, status=200) - self.assertTrue(b'login' in res.body) + self.assertTrue('login' in res.body) def test_logout_link_present_when_logged_in(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'Logout' in res.body) + self.assertTrue('Logout' in res.body) def test_logout_link_not_present_after_logged_out(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage', status=200) res = self.testapp.get('/logout', status=302) - self.assertTrue(b'Logout' not in res.body) + self.assertTrue('Logout' not in res.body) def test_anonymous_user_cannot_edit(self): res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue('Login' in res.body) def test_anonymous_user_cannot_add(self): res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue('Login' in res.body) def test_viewer_user_cannot_edit(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue('Login' in res.body) def test_viewer_user_cannot_add(self): res = self.testapp.get( self.viewer_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Login' in res.body) + self.assertTrue('Login' in res.body) def test_editors_member_user_can_edit(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/FrontPage/edit_page', status=200) - self.assertTrue(b'Editing' in res.body) + self.assertTrue('Editing' in res.body) def test_editors_member_user_can_add(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/add_page/NewPage', status=200) - self.assertTrue(b'Editing' in res.body) + self.assertTrue('Editing' in res.body) def test_editors_member_user_can_view(self): res = self.testapp.get( self.editor_login, status=302) res = self.testapp.get('/FrontPage', status=200) - self.assertTrue(b'FrontPage' in res.body) + self.assertTrue('FrontPage' in res.body) -- cgit v1.2.3 From b0b09c9bfb4924ce22627f4da94d3216829d5ec8 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:00:47 -0500 Subject: update session to use a static salt and separate serialize funcs --- pyramid/session.py | 140 +++++++++++++++++++------------------- pyramid/tests/test_session.py | 154 +++++++++++++++++++++--------------------- 2 files changed, 145 insertions(+), 149 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index 3c493a561..800400223 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -120,7 +120,8 @@ def check_csrf_token(request, return True def BaseCookieSessionFactory( - serializer, + serialize, + deserialize, cookie_name='session', max_age=None, path='/', @@ -151,13 +152,13 @@ def BaseCookieSessionFactory( Parameters: - ``serializer`` - An object with 2 methods, ``loads`` and ``dumps``, which will be used - to perform serialization and deserialization. - - ``dumps(value)`` should accept a Python object and return a - bytestring which can later be deserialized with ``loads``. - - ``loads(value)`` should expect to receive a bytestring, generated by - ``dumps`` and return a Python object. + ``serialize`` + A callable accepting a Python object and returning a bytestring. A + ``ValueError`` should be raised for malformed inputs. + + ``deserialize`` + A callable accepting a bytestring and returning a Python object. A + ``ValueError`` should be raised for malformed inputs. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. @@ -235,8 +236,9 @@ def BaseCookieSessionFactory( cookieval = request.cookies.get(self._cookie_name) if cookieval is not None: try: - value = serializer.loads(bytes_(cookieval)) + value = deserialize(bytes_(cookieval)) except ValueError: + # the cookie failed to deserialize, dropped value = None if value is not None: @@ -244,8 +246,12 @@ def BaseCookieSessionFactory( renewed, created, state = value new = False if now - renewed > self._timeout: + # expire the session because it was not renewed + # before the timeout threshold state = {} except TypeError: + # value failed to unpack properly or renewed was not + # a numeric type so we'll fail deserialization here state = {} self.created = created @@ -328,7 +334,7 @@ def BaseCookieSessionFactory( exception = getattr(self.request, 'exception', None) if exception is not None: # dont set a cookie during exceptions return False - cookieval = native_(serializer.dumps( + cookieval = native_(serialize( (self.accessed, self.created, dict(self)) )) if len(cookieval) > 4064: @@ -418,15 +424,9 @@ def UnencryptedCookieSessionFactoryConfig( is valid. Default: ``signed_deserialize`` (using pickle). """ - class _Serializer(object): - def dumps(self, appstruct): - return signed_serialize(appstruct, secret) - - def loads(self, bstruct): - return signed_deserialize(bstruct, secret) - return BaseCookieSessionFactory( - _Serializer(), + lambda v: signed_serialize(v, secret), + lambda v: signed_deserialize(v, secret), cookie_name=cookie_name, max_age=cookie_max_age, path=cookie_path, @@ -450,7 +450,9 @@ def SignedCookieSessionFactory( timeout=1200, reissue_time=0, hashalg='sha512', - serializer=None, + salt='pyramid.session.', + serialize=None, + deserialize=None, ): """ Configure a :term:`session factory` which will provide signed @@ -469,12 +471,19 @@ def SignedCookieSessionFactory( Parameters: ``secret`` - A string which is used to sign the cookie. + A string which is used to sign the cookie. The secret should be at + least as long as the block size of the selected hash algorithm. For + ``sha512`` this would mean a 128 bit (64 character) secret. ``hashalg`` The HMAC digest algorithm to use for signing. The algorithm must be supported by the :mod:`hashlib` library. Default: ``'sha512'``. + ``salt`` + A namespace to avoid collisions between different uses of a shared + secret. Reusing a secret for different parts of an application is + strongly discouraged. + ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. @@ -520,77 +529,64 @@ def SignedCookieSessionFactory( If ``True``, set a session cookie even if an exception occurs while rendering a view. Default: ``True``. - ``serializer`` - An object with 2 methods, ``loads`` and ``dumps``, which will be used - to perform serialization and deserialization. - - ``dumps(value)`` should accept a Python object and return a - bytestring which can later be deserialized with ``loads``. - - ``loads(value)`` should expect to receive a bytestring, generated by - ``dumps`` and return a Python object. + ``serialize`` + A callable accepting a Python object and returning a bytestring. A + ``ValueError`` should be raised for malformed inputs. + Default: :func:`pickle.dumps`. + + ``deserialize`` + A callable accepting a bytestring and returning a Python object. A + ``ValueError`` should be raised for malformed inputs. + Default: :func:`pickle.loads`. .. versionadded: 1.5a3 """ - if serializer is None: - serializer = _PickleSerializer() - - signed_serializer = _SignedSerializer(secret, hashalg, serializer) - - return BaseCookieSessionFactory( - signed_serializer, - cookie_name=cookie_name, - max_age=max_age, - path=path, - domain=domain, - secure=secure, - httponly=httponly, - timeout=timeout, - reissue_time=reissue_time, - set_on_exception=set_on_exception, - ) - -class _PickleSerializer(object): - def dumps(self, appstruct): - return pickle.dumps(appstruct, pickle.HIGHEST_PROTOCOL) + if serialize is None: + serialize = lambda v: pickle.dumps(v, pickle.HIGHEST_PROTOCOL) - def loads(self, bstruct): - return pickle.loads(bstruct) + if deserialize is None: + deserialize = pickle.loads -class _SignedSerializer(object): - def __init__(self, secret, hashalg, serializer): - self.secret = secret - self.digestmod = lambda: hashlib.new(hashalg) - self.digest_size = self.digestmod().digest_size - self.salt_size = 8 - self.serializer = serializer + digestmod = lambda: hashlib.new(hashalg) + digest_size = digestmod().digest_size - def derive_key(self, salt): - return hmac.new(bytes_(self.secret), salt, self.digestmod).digest() + salted_secret = bytes_(salt or '') + bytes_(secret) - def dumps(self, appstruct): - salt = os.urandom(self.salt_size) - derived_secret = self.derive_key(salt) - cstruct = self.serializer.dumps(appstruct) - sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() - return base64.b64encode(cstruct + salt + sig) + def signed_serialize(appstruct): + cstruct = serialize(appstruct) + sig = hmac.new(salted_secret, cstruct, digestmod).digest() + return base64.b64encode(cstruct + sig) - def loads(self, bstruct): + def signed_deserialize(bstruct): try: fstruct = base64.b64decode(bstruct) except (binascii.Error, TypeError) as e: raise ValueError('Badly formed base64 data: %s' % e) - cstruct_size = len(fstruct) - self.salt_size - self.digest_size + cstruct_size = len(fstruct) - digest_size if cstruct_size < 0: raise ValueError('Input is too short.') cstruct = fstruct[:cstruct_size] - salt = fstruct[cstruct_size:cstruct_size + self.salt_size] - expected_sig = fstruct[-self.digest_size:] + expected_sig = fstruct[-digest_size:] - derived_secret = self.derive_key(salt) - sig = hmac.new(derived_secret, cstruct, self.digestmod).digest() + sig = hmac.new(salted_secret, cstruct, digestmod).digest() if strings_differ(sig, expected_sig): raise ValueError('Invalid signature') - return self.serializer.loads(cstruct) + return deserialize(cstruct) + + return BaseCookieSessionFactory( + signed_serialize, + signed_deserialize, + cookie_name=cookie_name, + max_age=max_age, + path=path, + domain=domain, + secure=secure, + httponly=httponly, + timeout=timeout, + reissue_time=reissue_time, + set_on_exception=set_on_exception, + ) diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 0e1ed78a6..04740b0cd 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -264,7 +264,8 @@ class SharedCookieSessionTests(object): class TestBaseCookieSession(SharedCookieSessionTests, unittest.TestCase): def _makeOne(self, request, **kw): from pyramid.session import BaseCookieSessionFactory - return BaseCookieSessionFactory(DummySerializer(), **kw)(request) + return BaseCookieSessionFactory( + dummy_serialize, dummy_deserialize, **kw)(request) def _serialize(self, value): return json.dumps(value) @@ -281,13 +282,19 @@ class TestBaseCookieSession(SharedCookieSessionTests, unittest.TestCase): class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): def _makeOne(self, request, **kw): from pyramid.session import SignedCookieSessionFactory - return SignedCookieSessionFactory('secret', **kw)(request) + kw.setdefault('secret', 'secret') + return SignedCookieSessionFactory(**kw)(request) - def _serialize(self, value): - from pyramid.session import _SignedSerializer, _PickleSerializer - serializer = _PickleSerializer() - serializer = _SignedSerializer('secret', 'sha512', serializer) - return serializer.dumps(value) + def _serialize(self, value, salt='pyramid.session.', hashalg='sha512'): + import base64 + import hashlib + import hmac + import pickle + + digestmod = lambda: hashlib.new(hashalg) + cstruct = pickle.dumps(value, pickle.HIGHEST_PROTOCOL) + sig = hmac.new(salt + 'secret', cstruct, digestmod).digest() + return base64.b64encode(cstruct + sig) def test_reissue_not_triggered(self): import time @@ -298,17 +305,71 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): self.assertEqual(session['state'], 1) self.assertFalse(session._dirty) + def test_custom_salt(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1}), salt='f.') + request.cookies['session'] = cookieval + session = self._makeOne(request, salt='f.') + self.assertEqual(session['state'], 1) + + def test_salt_mismatch(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1}), salt='f.') + request.cookies['session'] = cookieval + session = self._makeOne(request, salt='g.') + self.assertEqual(session, {}) + + def test_custom_hashalg(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1}), + hashalg='sha1') + request.cookies['session'] = cookieval + session = self._makeOne(request, hashalg='sha1') + self.assertEqual(session['state'], 1) + + def test_hashalg_mismatch(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1}), + hashalg='sha1') + request.cookies['session'] = cookieval + session = self._makeOne(request, hashalg='sha256') + self.assertEqual(session, {}) + + def test_secret_mismatch(self): + import time + request = testing.DummyRequest() + cookieval = self._serialize((time.time(), 0, {'state': 1})) + request.cookies['session'] = cookieval + session = self._makeOne(request, secret='evilsecret') + self.assertEqual(session, {}) + def test_custom_serializer(self): + import base64 + from hashlib import sha512 + import hmac import time - from pyramid.session import _SignedSerializer - serializer = DummySerializer() - signer = _SignedSerializer('secret', 'sha512', serializer=serializer) - cookieval = signer.dumps((time.time(), 0, {'state': 1})) request = testing.DummyRequest() + cstruct = dummy_serialize((time.time(), 0, {'state': 1})) + sig = hmac.new('pyramid.session.secret', cstruct, sha512).digest() + cookieval = base64.b64encode(cstruct + sig) request.cookies['session'] = cookieval - session = self._makeOne(request, serializer=serializer) + session = self._makeOne(request, deserialize=dummy_deserialize) self.assertEqual(session['state'], 1) + def test_invalid_data_size(self): + from hashlib import sha512 + import base64 + request = testing.DummyRequest() + num_bytes = sha512().digest_size - 1 + cookieval = base64.b64encode(b' ' * num_bytes) + request.cookies['session'] = cookieval + session = self._makeOne(request) + self.assertEqual(session, {}) + class TestUnencryptedCookieSession(SharedCookieSessionTests, unittest.TestCase): def _makeOne(self, request, **kw): from pyramid.session import UnencryptedCookieSessionFactoryConfig @@ -471,66 +532,6 @@ class Test_signed_deserialize(unittest.TestCase): serialized = 'bad' + serialize('123', 'secret') self.assertRaises(ValueError, self._callFUT, serialized, 'secret') -class TestSignedSerializer(unittest.TestCase): - def _makeOne(self, secret='secret', hashalg='sha512'): - from pyramid.session import _SignedSerializer - serializer = DummySerializer() - return _SignedSerializer(secret, hashalg, serializer) - - def test_it_same_serializer(self): - serializer = self._makeOne() - appstruct = {'state': 1} - cstruct = serializer.dumps(appstruct) - result = serializer.loads(cstruct) - self.assertEqual(result, appstruct) - - def test_it_different_serializers(self): - serializer1 = self._makeOne() - appstruct = {'state': 1} - cstruct = serializer1.dumps(appstruct) - - serializer2 = self._makeOne() - result = serializer2.loads(cstruct) - self.assertEqual(result, appstruct) - - def test_invalid_signature_with_different_secret(self): - serializer1 = self._makeOne('secret1') - appstruct = {'state': 1} - cstruct = serializer1.dumps(appstruct) - - serializer2 = self._makeOne('secret2') - try: - serializer2.loads(cstruct) - except ValueError as exc: - self.assertTrue('Invalid signature' in exc.args[0]) - else: # pragma: no cover - self.fail() - - def test_invalid_signature_after_tamper(self): - import base64 - serializer = self._makeOne() - appstruct = {'state': 1} - cstruct = serializer.dumps(appstruct) - actual_val = base64.b64decode(cstruct) - test_val = base64.b64encode(actual_val[1:]) - try: - serializer.loads(test_val) - except ValueError as exc: - self.assertTrue('Invalid signature' in exc.args[0]) - else: # pragma: no cover - self.fail() - - def test_invalid_data_size(self): - import base64 - serializer = self._makeOne() - num_bytes = serializer.digest_size + serializer.salt_size - 1 - try: - serializer.loads(base64.b64encode(b' ' * num_bytes)) - except ValueError as exc: - self.assertTrue('Input is too short' in exc.args[0]) - else: # pragma: no cover - self.fail() - class Test_check_csrf_token(unittest.TestCase): def _callFUT(self, *args, **kwargs): from ..session import check_csrf_token @@ -566,12 +567,11 @@ class Test_check_csrf_token(unittest.TestCase): result = self._callFUT(request, 'csrf_token', raises=False) self.assertEqual(result, False) -class DummySerializer(object): - def dumps(self, value): - return json.dumps(value).encode('utf-8') +def dummy_serialize(value): + return json.dumps(value).encode('utf-8') - def loads(self, value): - return json.loads(value.decode('utf-8')) +def dummy_deserialize(value): + return json.loads(value.decode('utf-8')) class DummySessionFactory(dict): _dirty = False -- cgit v1.2.3 From 10c6857185e299b4c6932c2a378ad3adb14867d8 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:12:20 -0500 Subject: add deprecation for old cookie factory --- CHANGES.txt | 6 ++++++ pyramid/session.py | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 550dd0a39..feea11def 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -15,6 +15,12 @@ Bug Fixes allowing traversal to continue. See https://github.com/Pylons/pyramid/issues/1104 +Deprecations +------------ + +- The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and will + be replaced by the ``SignedCookieSessionFactory``. + 1.5a2 (2013-09-22) ================== diff --git a/pyramid/session.py b/pyramid/session.py index 800400223..803d56066 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -1,10 +1,10 @@ -import hashlib -from hashlib import sha1 import base64 import binascii +import hashlib import hmac -import time import os +import time +import warnings from zope.interface import implementer @@ -55,7 +55,7 @@ def signed_serialize(data, secret): response.set_cookie('signed_cookie', cookieval) """ pickled = pickle.dumps(data, pickle.HIGHEST_PROTOCOL) - sig = hmac.new(bytes_(secret), pickled, sha1).hexdigest() + sig = hmac.new(bytes_(secret), pickled, hashlib.sha1).hexdigest() return sig + native_(base64.b64encode(pickled)) def signed_deserialize(serialized, secret, hmac=hmac): @@ -79,7 +79,7 @@ def signed_deserialize(serialized, secret, hmac=hmac): # Badly formed data can make base64 die raise ValueError('Badly formed base64 data: %s' % e) - sig = bytes_(hmac.new(bytes_(secret), pickled, sha1).hexdigest()) + sig = bytes_(hmac.new(bytes_(secret), pickled, hashlib.sha1).hexdigest()) # Avoid timing attacks (see # http://seb.dbzteam.org/crypto/python-oauth-timing-hmac.pdf) @@ -424,6 +424,14 @@ def UnencryptedCookieSessionFactoryConfig( is valid. Default: ``signed_deserialize`` (using pickle). """ + warnings.warn( + ('The UnencryptedCookieSessionFactoryConfig is deprecated as of ' + 'Pyramid 1.5, and will be replaced by the ' + 'SignedCookieSessionFactory in future versions.'), + DeprecationWarning, + stacklevel=2 + ) + return BaseCookieSessionFactory( lambda v: signed_serialize(v, secret), lambda v: signed_deserialize(v, secret), -- cgit v1.2.3 From 554a020f91553d60efb449d0d23ea3e37ecdb42d Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:15:48 -0500 Subject: fix tests on python 3 --- pyramid/tests/test_session.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 04740b0cd..382cf8eb5 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -285,7 +285,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): kw.setdefault('secret', 'secret') return SignedCookieSessionFactory(**kw)(request) - def _serialize(self, value, salt='pyramid.session.', hashalg='sha512'): + def _serialize(self, value, salt=b'pyramid.session.', hashalg='sha512'): import base64 import hashlib import hmac @@ -293,7 +293,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): digestmod = lambda: hashlib.new(hashalg) cstruct = pickle.dumps(value, pickle.HIGHEST_PROTOCOL) - sig = hmac.new(salt + 'secret', cstruct, digestmod).digest() + sig = hmac.new(salt + b'secret', cstruct, digestmod).digest() return base64.b64encode(cstruct + sig) def test_reissue_not_triggered(self): @@ -308,17 +308,17 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): def test_custom_salt(self): import time request = testing.DummyRequest() - cookieval = self._serialize((time.time(), 0, {'state': 1}), salt='f.') + cookieval = self._serialize((time.time(), 0, {'state': 1}), salt=b'f.') request.cookies['session'] = cookieval - session = self._makeOne(request, salt='f.') + session = self._makeOne(request, salt=b'f.') self.assertEqual(session['state'], 1) def test_salt_mismatch(self): import time request = testing.DummyRequest() - cookieval = self._serialize((time.time(), 0, {'state': 1}), salt='f.') + cookieval = self._serialize((time.time(), 0, {'state': 1}), salt=b'f.') request.cookies['session'] = cookieval - session = self._makeOne(request, salt='g.') + session = self._makeOne(request, salt=b'g.') self.assertEqual(session, {}) def test_custom_hashalg(self): @@ -354,7 +354,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): import time request = testing.DummyRequest() cstruct = dummy_serialize((time.time(), 0, {'state': 1})) - sig = hmac.new('pyramid.session.secret', cstruct, sha512).digest() + sig = hmac.new(b'pyramid.session.secret', cstruct, sha512).digest() cookieval = base64.b64encode(cstruct + sig) request.cookies['session'] = cookieval session = self._makeOne(request, deserialize=dummy_deserialize) -- cgit v1.2.3 From 63bf0587066216f9879ab188691579c9565f0340 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:22:35 -0500 Subject: updated changelog --- CHANGES.txt | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index feea11def..a9b9814f3 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -6,6 +6,21 @@ Documentation - Added a "Quick Tutorial" to go with the Quick Tour +Features +-------- + +- Added a new ``SignedCookieSessionFactory`` which is very similar to the + ``UnencryptedCookieSessionFactoryConfig`` but with a clearer focus on + signing content. The custom serializer arguments to this function should + only focus on serializing, unlike its predecessor which required the + serializer to also perform signing. + +- Added a new ``BaseCookieSessionFactory`` which acts as a generic cookie + factory that can be used by framework implementors to create their own + session implementations. It provides a reusable API which focuses strictly + on providing a dictionary-like object that properly handles renewals, + timeouts, and conformance with the ``ISession`` API. + Bug Fixes --------- @@ -18,9 +33,9 @@ Bug Fixes Deprecations ------------ -- The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and will - be replaced by the ``SignedCookieSessionFactory``. - +- The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and is + superceded by the ``SignedCookieSessionFactory``. Cookies generated by + the two factories are not compatible. 1.5a2 (2013-09-22) ================== -- cgit v1.2.3 From 8df7a71d99bbeb7819e8a2752012d51202669aa6 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:30:58 -0500 Subject: update the docs --- docs/api/session.rst | 8 ++++++-- docs/narr/sessions.rst | 19 +++++++++---------- docs/quick_tour/package/hello_world/__init__.py | 4 ++-- docs/quick_tour/package/hello_world/init.py | 4 ++-- docs/quick_tutorial/sessions/tutorial/__init__.py | 6 +++--- pyramid/session.py | 2 +- 6 files changed, 23 insertions(+), 20 deletions(-) diff --git a/docs/api/session.rst b/docs/api/session.rst index 31bc196ad..dde9d20e9 100644 --- a/docs/api/session.rst +++ b/docs/api/session.rst @@ -5,12 +5,16 @@ .. automodule:: pyramid.session - .. autofunction:: UnencryptedCookieSessionFactoryConfig - .. autofunction:: signed_serialize .. autofunction:: signed_deserialize .. autofunction:: check_csrf_token + .. autofunction:: SignedCookieSessionFactory + + .. autofunction:: UnencryptedCookieSessionFactoryConfig + + .. autofunction:: BaseCookieSessionFactory + diff --git a/docs/narr/sessions.rst b/docs/narr/sessions.rst index 358977089..1d914f9ea 100644 --- a/docs/narr/sessions.rst +++ b/docs/narr/sessions.rst @@ -43,24 +43,23 @@ limitations: It is digitally signed, however, and thus its data cannot easily be tampered with. -You can configure this session factory in your :app:`Pyramid` -application by using the ``session_factory`` argument to the -:class:`~pyramid.config.Configurator` class: +You can configure this session factory in your :app:`Pyramid` application +by using the :meth:`pyramid.config.Configurator.set_session_factory`` method. .. code-block:: python :linenos: - from pyramid.session import UnencryptedCookieSessionFactoryConfig - my_session_factory = UnencryptedCookieSessionFactoryConfig('itsaseekreet') - + from pyramid.session import SignedCookieSessionFactory + my_session_factory = SignedCookieSessionFactory('itsaseekreet') + from pyramid.config import Configurator - config = Configurator(session_factory = my_session_factory) + config = Configurator() + config.set_session_factory(my_session_factory) .. warning:: - Note the very long, very explicit name for - ``UnencryptedCookieSessionFactoryConfig``. It's trying to tell you that - this implementation is, by default, *unencrypted*. You should not use it + By default the :func:`~pyramid.session.SignedCookieSessionFactory` + implementation is *unencrypted*. 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. And if you use this diff --git a/docs/quick_tour/package/hello_world/__init__.py b/docs/quick_tour/package/hello_world/__init__.py index 6e66bf40a..4a4fbec30 100644 --- a/docs/quick_tour/package/hello_world/__init__.py +++ b/docs/quick_tour/package/hello_world/__init__.py @@ -1,7 +1,7 @@ from pyramid.config import Configurator from pyramid_jinja2 import renderer_factory # Start Sphinx Include 1 -from pyramid.session import UnencryptedCookieSessionFactoryConfig +from pyramid.session import SignedCookieSessionFactory # End Sphinx Include 1 from hello_world.models import get_root @@ -16,7 +16,7 @@ def main(global_config, **settings): settings.setdefault('jinja2.i18n.domain', 'hello_world') # Start Sphinx Include 2 - my_session_factory = UnencryptedCookieSessionFactoryConfig('itsaseekreet') + my_session_factory = SignedCookieSessionFactory('itsaseekreet') config = Configurator(root_factory=get_root, settings=settings, session_factory=my_session_factory) # End Sphinx Include 2 diff --git a/docs/quick_tour/package/hello_world/init.py b/docs/quick_tour/package/hello_world/init.py index 9d7ec43d8..5b5f6a118 100644 --- a/docs/quick_tour/package/hello_world/init.py +++ b/docs/quick_tour/package/hello_world/init.py @@ -1,7 +1,7 @@ from pyramid.config import Configurator from pyramid_jinja2 import renderer_factory # Start Sphinx 1 -from pyramid.session import UnencryptedCookieSessionFactoryConfig +from pyramid.session import SignedCookieSessionFactory # End Sphinx 1 from hello_world.models import get_root @@ -22,7 +22,7 @@ def main(global_config, **settings): # End Include # Start Sphinx Include 2 - my_session_factory = UnencryptedCookieSessionFactoryConfig('itsaseekreet') + my_session_factory = SignedCookieSessionFactory('itsaseekreet') config = Configurator(session_factory=my_session_factory) # End Sphinx Include 2 diff --git a/docs/quick_tutorial/sessions/tutorial/__init__.py b/docs/quick_tutorial/sessions/tutorial/__init__.py index ecf57bb32..9ddc2e1b1 100644 --- a/docs/quick_tutorial/sessions/tutorial/__init__.py +++ b/docs/quick_tutorial/sessions/tutorial/__init__.py @@ -1,9 +1,9 @@ from pyramid.config import Configurator -from pyramid.session import UnencryptedCookieSessionFactoryConfig +from pyramid.session import SignedCookieSessionFactory def main(global_config, **settings): - my_session_factory = UnencryptedCookieSessionFactoryConfig( + my_session_factory = SignedCookieSessionFactory( 'itsaseekreet') config = Configurator(settings=settings, session_factory=my_session_factory) @@ -11,4 +11,4 @@ def main(global_config, **settings): config.add_route('home', '/') config.add_route('hello', '/howdy') config.scan('.views') - return config.make_wsgi_app() \ No newline at end of file + return config.make_wsgi_app() diff --git a/pyramid/session.py b/pyramid/session.py index 803d56066..6ffef7a22 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -490,7 +490,7 @@ def SignedCookieSessionFactory( ``salt`` A namespace to avoid collisions between different uses of a shared secret. Reusing a secret for different parts of an application is - strongly discouraged. + strongly discouraged. Default: ``'pyramid.session.'``. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. -- cgit v1.2.3 From 0e2914bc0d5f6f4cab1cfe11e3c6e88dd96ecbb6 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:43:17 -0500 Subject: move HTTPBadCSRFToken to p.exceptions.BadCSRFToken --- pyramid/exceptions.py | 15 +++++++++++++++ pyramid/httpexceptions.py | 21 --------------------- pyramid/session.py | 6 +++--- pyramid/tests/test_exceptions.py | 6 ++++++ pyramid/tests/test_session.py | 4 ++-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pyramid/exceptions.py b/pyramid/exceptions.py index a8fca1d84..c59d109df 100644 --- a/pyramid/exceptions.py +++ b/pyramid/exceptions.py @@ -1,4 +1,5 @@ from pyramid.httpexceptions import ( + HTTPBadRequest, HTTPNotFound, HTTPForbidden, ) @@ -8,6 +9,20 @@ Forbidden = HTTPForbidden # bw compat CR = '\n' +class BadCSRFToken(HTTPBadRequest): + """ + This exception indicates the request has failed cross-site request + forgery token validation. + """ + title = 'Bad CSRF Token' + explanation = ( + 'Access is denied. This server can not verify that your cross-site ' + 'request forgery token belongs to your login session. Either you ' + 'supplied the wrong cross-site request forgery token or your session ' + 'no longer exists. This may be due to session timeout or because ' + 'browser is not supplying the credentials required, as can happen ' + 'when the browser has cookies turned off.') + class PredicateMismatch(HTTPNotFound): """ This exception is raised by multiviews when no view matches diff --git a/pyramid/httpexceptions.py b/pyramid/httpexceptions.py index 21d862a6b..5e8d8ccd8 100644 --- a/pyramid/httpexceptions.py +++ b/pyramid/httpexceptions.py @@ -35,9 +35,6 @@ Exception HTTPError HTTPClientError * 400 - HTTPBadRequest - - * 400 - HTTPBadCSRFToken - * 401 - HTTPUnauthorized * 402 - HTTPPaymentRequired * 403 - HTTPForbidden @@ -581,24 +578,6 @@ class HTTPBadRequest(HTTPClientError): """ pass -class HTTPBadCSRFToken(HTTPClientError): - """ - subclass of :class:`~HTTPBadRequest` - - This indicates the request has failed cross-site request forgery token - validation. - - title: Bad CSRF Token - """ - title = 'Bad CSRF Token' - explanation = ( - 'Access is denied. This server can not verify that your cross-site ' - 'request forgery token belongs to your login session. Either you ' - 'supplied the wrong cross-site request forgery token or your session ' - 'no longer exists. This may be due to session timeout or because ' - 'browser is not supplying the credentials required, as can happen ' - 'when the browser has cookies turned off.') - class HTTPUnauthorized(HTTPClientError): """ subclass of :class:`~HTTPClientError` diff --git a/pyramid/session.py b/pyramid/session.py index 72b69117c..d3318cbda 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -15,7 +15,7 @@ from pyramid.compat import ( native_, ) -from pyramid.httpexceptions import HTTPBadCSRFToken +from pyramid.exceptions import BadCSRFToken from pyramid.interfaces import ISession from pyramid.util import strings_differ @@ -95,7 +95,7 @@ def check_csrf_token(request, If the value supplied by param or by header doesn't match the value supplied by ``request.session.get_csrf_token()``, and ``raises`` is ``True``, this function will raise an - :exc:`pyramid.httpexceptions.HTTPBadCSRFToken` exception. + :exc:`pyramid.exceptions.BadCSRFToken` exception. If the check does succeed and ``raises`` is ``False``, this function will return ``False``. If the CSRF check is successful, this function will return ``True`` unconditionally. @@ -108,7 +108,7 @@ def check_csrf_token(request, supplied_token = request.params.get(token, request.headers.get(header)) if supplied_token != request.session.get_csrf_token(): if raises: - raise HTTPBadCSRFToken('check_csrf_token(): Invalid token') + raise BadCSRFToken('check_csrf_token(): Invalid token') return False return True diff --git a/pyramid/tests/test_exceptions.py b/pyramid/tests/test_exceptions.py index aa5ebb376..993209046 100644 --- a/pyramid/tests/test_exceptions.py +++ b/pyramid/tests/test_exceptions.py @@ -11,6 +11,12 @@ class TestBWCompat(unittest.TestCase): from pyramid.httpexceptions import HTTPForbidden as two self.assertTrue(one is two) +class TestBadCSRFToken(unittest.TestCase): + def test_response_equivalence(self): + from pyramid.exceptions import BadCSRFToken + from pyramid.httpexceptions import HTTPBadRequest + self.assertTrue(isinstance(BadCSRFToken(), HTTPBadRequest)) + class TestNotFound(unittest.TestCase): def _makeOne(self, message): from pyramid.exceptions import NotFound diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index a928af43e..9337ab8eb 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -381,9 +381,9 @@ class Test_check_csrf_token(unittest.TestCase): self.assertEqual(self._callFUT(request), True) def test_failure_raises(self): - from pyramid.httpexceptions import HTTPBadCSRFToken + from pyramid.exceptions import BadCSRFToken request = testing.DummyRequest() - self.assertRaises(HTTPBadCSRFToken, self._callFUT, request, + self.assertRaises(BadCSRFToken, self._callFUT, request, 'csrf_token') def test_failure_no_raises(self): -- cgit v1.2.3 From 6b0889cc8f3711d5f77cb663f8f2fa432eb3ad06 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 01:52:11 -0500 Subject: update doc references --- CHANGES.txt | 5 +++++ docs/api/exceptions.rst | 2 ++ docs/api/httpexceptions.rst | 13 ------------- pyramid/httpexceptions.py | 17 +++++++---------- 4 files changed, 14 insertions(+), 23 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index a228fbb3a..fcfb83e4f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -10,6 +10,11 @@ Features python -3 -m pyramid.scripts.pserve development.ini +- Added a specific subclass of ``HTTPBadRequest`` named + ``pyramid.exceptions.BadCSRFToken`` which will now be raised in response + to failures in ``check_csrf_token``. + See https://github.com/Pylons/pyramid/pull/1149 + Bug Fixes --------- diff --git a/docs/api/exceptions.rst b/docs/api/exceptions.rst index ab158f18d..0c630571f 100644 --- a/docs/api/exceptions.rst +++ b/docs/api/exceptions.rst @@ -5,6 +5,8 @@ .. automodule:: pyramid.exceptions + .. autoclass:: BadCSRFToken + .. autoclass:: PredicateMismatch .. autoclass:: Forbidden diff --git a/docs/api/httpexceptions.rst b/docs/api/httpexceptions.rst index 0fdd0f0e9..b50f10beb 100644 --- a/docs/api/httpexceptions.rst +++ b/docs/api/httpexceptions.rst @@ -10,9 +10,6 @@ A mapping of integer status code to HTTP exception class (eg. the integer "401" maps to :class:`pyramid.httpexceptions.HTTPUnauthorized`). All mapped exception classes are children of :class:`pyramid.httpexceptions`, - i.e. the :ref:`pyramid_specific_http_exceptions` such as - :class:`pyramid.httpexceptions.HTTPBadRequest.BadCSRFToken` are not - mapped. .. autofunction:: exception_response @@ -109,13 +106,3 @@ .. autoclass:: HTTPVersionNotSupported .. autoclass:: HTTPInsufficientStorage - - -.. _pyramid_specific_http_exceptions: - -Pyramid-specific HTTP Exceptions -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Each Pyramid-specific HTTP exception has the status code of it's parent. - - .. autoclass:: HTTPBadCSRFToken diff --git a/pyramid/httpexceptions.py b/pyramid/httpexceptions.py index 5e8d8ccd8..ebee39ada 100644 --- a/pyramid/httpexceptions.py +++ b/pyramid/httpexceptions.py @@ -2,13 +2,10 @@ HTTP Exceptions --------------- -This module contains Pyramid HTTP exception classes. Each class is a subclass -of the :class:`~HTTPException`. Each class relates to a single HTTP status -code, although the reverse is not true. There are -:ref:`pyramid_specific_http_exceptions` which are sub-classes of the -:rfc:`2608` HTTP status codes. Each of these Pyramid-specific exceptions have -the status code of it's parent. Each exception class is also a -:term:`response` object. +This module contains Pyramid HTTP exception classes. Each class relates to a +single HTTP status code. Each class is a subclass of the +:class:`~HTTPException`. Each exception class is also a :term:`response` +object. Each exception class has a status code according to :rfc:`2068`: codes with 100-300 are not really errors; 400s are client errors, @@ -571,10 +568,10 @@ class HTTPBadRequest(HTTPClientError): """ subclass of :class:`~HTTPClientError` - base class for Pyramid-specific validity checks of the client's request + This indicates that the body or headers failed validity checks, + preventing the server from being able to continue processing. - This class and it's sub-classes result in a '400 Bad Request' HTTP status, - although it's sub-classes specialize the 'Bad Request' text. + code: 400, title: Bad Request """ pass -- cgit v1.2.3 From 8385569b371a2586acf1680937ca656136c2502c Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 02:02:19 -0500 Subject: reference github issues --- CHANGES.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index a9b9814f3..f67291ca5 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -14,12 +14,14 @@ Features signing content. The custom serializer arguments to this function should only focus on serializing, unlike its predecessor which required the serializer to also perform signing. + See https://github.com/Pylons/pyramid/pull/1142 - Added a new ``BaseCookieSessionFactory`` which acts as a generic cookie factory that can be used by framework implementors to create their own session implementations. It provides a reusable API which focuses strictly on providing a dictionary-like object that properly handles renewals, timeouts, and conformance with the ``ISession`` API. + See https://github.com/Pylons/pyramid/pull/1142 Bug Fixes --------- @@ -36,6 +38,7 @@ Deprecations - The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and is superceded by the ``SignedCookieSessionFactory``. Cookies generated by the two factories are not compatible. + See https://github.com/Pylons/pyramid/pull/1142 1.5a2 (2013-09-22) ================== -- cgit v1.2.3 From 604297a083419278d85be47e40d1905043c38460 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 03:10:54 -0500 Subject: attempt to decode basic header as utf-8 and fallback to latin-1 fixes #898 fixes #904 --- pyramid/authentication.py | 9 ++++++++- pyramid/tests/test_authentication.py | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/pyramid/authentication.py b/pyramid/authentication.py index 454ebd4b2..6b6fbd041 100644 --- a/pyramid/authentication.py +++ b/pyramid/authentication.py @@ -1176,10 +1176,17 @@ class BasicAuthAuthenticationPolicy(CallbackAuthenticationPolicy): return None if authmeth.lower() != 'basic': return None + try: - auth = b64decode(auth.strip()).decode('ascii') + authbytes = b64decode(auth.strip()) except (TypeError, binascii.Error): # can't decode return None + + try: + auth = authbytes.decode('utf-8') + except UnicodeDecodeError: + auth = authbytes.decode('latin-1') + try: username, password = auth.split(':', 1) except ValueError: # not enough values to unpack diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index 19e95cf9a..ed6cc5903 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1378,11 +1378,25 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): import base64 request = testing.DummyRequest() inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') - request.headers['Authorization'] = 'Basic %s' % base64.b64encode(inputs.encode('utf-8')) + request.headers['Authorization'] = 'Basic %s' % ( + base64.b64encode(inputs.encode('utf-8'))) def check(username, password, request): return [] policy = self._makeOne(check) - self.assertEqual(policy.authenticated_userid(request), b'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) + self.assertEqual(policy.authenticated_userid(request), + b'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) + + def test_authenticated_userid_latin1(self): + import base64 + request = testing.DummyRequest() + inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') + request.headers['Authorization'] = 'Basic %s' % ( + base64.b64encode(inputs.encode('latin-1'))) + def check(username, password, request): + return [] + policy = self._makeOne(check) + self.assertEqual(policy.authenticated_userid(request), + b'm\xc3\xb6rk\xc3\xb6'.decode('utf-8')) def test_unauthenticated_userid_invalid_payload(self): import base64 -- cgit v1.2.3 From 42f0cb2923200f07c89e011f80fe15e3c65caf03 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 03:18:05 -0500 Subject: update changelog --- CHANGES.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index a228fbb3a..f170308b0 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -32,6 +32,12 @@ Bug Fixes - Remove unused ``renderer`` argument from ``Configurator.add_route``. +- Allow the ``BasicAuthenticationPolicy`` to work with non-ascii usernames + and passwords. The charset is not passed as part of the header and different + browsers alternate between UTF-8 and Latin-1, so the policy now attempts + to decode with UTF-8 first, and will fallback to Latin-1. + See https://github.com/Pylons/pyramid/pull/1170 + Documentation ------------- -- cgit v1.2.3 From 6c98b17ed9aadbe485c6473c3f76e1b2b529dc78 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 03:26:53 -0500 Subject: fix tests on py3 --- pyramid/tests/test_authentication.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pyramid/tests/test_authentication.py b/pyramid/tests/test_authentication.py index ed6cc5903..3ac8f2d61 100644 --- a/pyramid/tests/test_authentication.py +++ b/pyramid/tests/test_authentication.py @@ -1377,9 +1377,10 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def test_authenticated_userid_utf8(self): import base64 request = testing.DummyRequest() - inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') + inputs = (b'm\xc3\xb6rk\xc3\xb6:' + b'm\xc3\xb6rk\xc3\xb6password').decode('utf-8') request.headers['Authorization'] = 'Basic %s' % ( - base64.b64encode(inputs.encode('utf-8'))) + base64.b64encode(inputs.encode('utf-8')).decode('latin-1')) def check(username, password, request): return [] policy = self._makeOne(check) @@ -1389,9 +1390,10 @@ class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def test_authenticated_userid_latin1(self): import base64 request = testing.DummyRequest() - inputs = b'm\xc3\xb6rk\xc3\xb6:m\xc3\xb6rk\xc3\xb6password'.decode('utf-8') + inputs = (b'm\xc3\xb6rk\xc3\xb6:' + b'm\xc3\xb6rk\xc3\xb6password').decode('utf-8') request.headers['Authorization'] = 'Basic %s' % ( - base64.b64encode(inputs.encode('latin-1'))) + base64.b64encode(inputs.encode('latin-1')).decode('latin-1')) def check(username, password, request): return [] policy = self._makeOne(check) -- cgit v1.2.3 From b04ae5ac814266eb77d4a09c749e5e0394a11a1c Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 03:43:05 -0500 Subject: modify the docs for the renderer interfaces --- CHANGES.txt | 9 +++++++++ docs/api/interfaces.rst | 5 ++++- pyramid/interfaces.py | 53 +++++++++++++++++++++++++++++-------------------- 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index a228fbb3a..10ebe33ae 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -40,6 +40,9 @@ Documentation - Removed mention of ``pyramid_beaker`` from docs. Beaker is no longer maintained. Point people at ``pyramid_redis_sessions`` instead. +- Add documentation for ``pyramid.interfaces.IRendererFactory`` and + ``pyramid.interfaces.IRenderer``. + Backwards Incompatibilities --------------------------- @@ -50,6 +53,12 @@ Backwards Incompatibilities situation, leaving a query string of ``a=b&key=``. See https://github.com/Pylons/pyramid/issues/1119 +Deprecations +------------ + +- Deprecate the ``pyraid.interfaces.ITemplateRenderer`` interface. It is no + longer used since Mako and Chameleon have been split into their own packages. + 1.5a2 (2013-09-22) ================== diff --git a/docs/api/interfaces.rst b/docs/api/interfaces.rst index 1dea5fab0..d8d935afd 100644 --- a/docs/api/interfaces.rst +++ b/docs/api/interfaces.rst @@ -50,7 +50,10 @@ Other Interfaces .. autointerface:: IRendererInfo :members: - .. autointerface:: ITemplateRenderer + .. autointerface:: IRendererFactory + :members: + + .. autointerface:: IRenderer :members: .. autointerface:: IViewMapperFactory diff --git a/pyramid/interfaces.py b/pyramid/interfaces.py index 85b2227b4..cf651cf1e 100644 --- a/pyramid/interfaces.py +++ b/pyramid/interfaces.py @@ -367,9 +367,29 @@ class IBeforeRender(IDict): '``render`` method for this rendering. ' 'This feature is new in Pyramid 1.2.') +class IRendererInfo(Interface): + """ An object implementing this interface is passed to every + :term:`renderer factory` constructor as its only argument (conventionally + named ``info``)""" + name = Attribute('The value passed by the user as the renderer name') + package = Attribute('The "current package" when the renderer ' + 'configuration statement was found') + type = Attribute('The renderer type name') + registry = Attribute('The "current" application registry when the ' + 'renderer was created') + settings = Attribute('The deployment settings dictionary related ' + 'to the current application') + +class IRendererFactory(Interface): + def __call__(info): + """ Return an object that implements + :class:`pyramid.interfaces.IRenderer`. ``info`` is an + object that implements :class:`pyramid.interfaces.IRendererInfo`. + """ + class IRenderer(Interface): def __call__(value, system): - """ Call a the renderer implementation with the result of the + """ Call the renderer with the result of the view (``value``) passed in and return a result (a string or unicode object useful as a response body). Values computed by the system are passed by the system in the ``system`` @@ -387,6 +407,13 @@ class ITemplateRenderer(IRenderer): accepts arbitrary keyword arguments and returns a string or unicode object """ +deprecated( + 'ITemplateRenderer', + 'As of Pyramid 1.5 the, "pyramid.interfaces.ITemplateRenderer" interface ' + 'is scheduled to be removed. It was used by the Mako and Chameleon ' + 'renderers which have been split into their own packages.' + ) + class IViewMapper(Interface): def __call__(self, object): """ Provided with an arbitrary object (a function, class, or @@ -611,17 +638,13 @@ class ITraverser(Interface): ITraverserFactory = ITraverser # b / c for 1.0 code -class IRendererFactory(Interface): - def __call__(info): - """ Return an object that implements ``IRenderer``. ``info`` is an - object that implement ``IRendererInfo``. """ - class IViewPermission(Interface): def __call__(context, request): - """ Return True if the permission allows, return False if it denies. """ + """ Return True if the permission allows, return False if it denies. + """ class IRouter(Interface): - """WSGI application which routes requests to 'view' code based on + """ WSGI application which routes requests to 'view' code based on a view registry.""" registry = Attribute( """Component architecture registry local to this application.""") @@ -932,20 +955,6 @@ class ISession(IDict): returned. """ -class IRendererInfo(Interface): - """ An object implementing this interface is passed to every - :term:`renderer factory` constructor as its only argument (conventionally - named ``info``)""" - name = Attribute('The value passed by the user as the renderer name') - package = Attribute('The "current package" when the renderer ' - 'configuration statement was found') - type = Attribute('The renderer type name') - registry = Attribute('The "current" application registry when the ' - 'renderer was created') - settings = Attribute('The deployment settings dictionary related ' - 'to the current application') - - class IIntrospector(Interface): def get(category_name, discriminator, default=None): """ Get the IIntrospectable related to the category_name and the -- cgit v1.2.3 From 777112d521e337fefc2e0c217add7ac283d087b3 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 03:48:01 -0500 Subject: link to the public renderer interfaces --- docs/narr/renderers.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/narr/renderers.rst b/docs/narr/renderers.rst index 740c81555..4f8c4bf77 100644 --- a/docs/narr/renderers.rst +++ b/docs/narr/renderers.rst @@ -480,8 +480,11 @@ Adding a New Renderer You may add a new renderer by creating and registering a :term:`renderer factory`. -A renderer factory implementation is typically a class with the -following interface: +A renderer factory implementation should conform to the +:class:`pyramid.interfaces.IRendererFactory` interface. It should be capable +of creating an object that conforms to the +:class:`pyramid.interfaces.IRenderer` interface. A typical class that follows +this setup is as follows: .. code-block:: python :linenos: -- cgit v1.2.3 From 9536f9b9c470ea03de4bfa98b1f0c3583bb8f394 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sat, 19 Oct 2013 15:22:38 -0400 Subject: use zope.deprecation for warning about the UnencryptedCookieSessionFactoryConfig deprecation (it will happen at import time, rather than usage time, which is good for tests); add a few sphinx directives for deprecated and versionadded --- pyramid/session.py | 32 +++++++++++++++++++------------- pyramid/tests/test_session.py | 10 ++++++++++ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index 6ffef7a22..60a5f7a63 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -4,8 +4,8 @@ import hashlib import hmac import os import time -import warnings +from zope.deprecation import deprecated from zope.interface import implementer from pyramid.compat import ( @@ -133,11 +133,13 @@ def BaseCookieSessionFactory( set_on_exception=True, ): """ + .. versionadded:: 1.5 + Configure a :term:`session factory` which will provide 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.config.Configurator` constructor, or used - as the ``session_factory`` argument of the + 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.config.Configurator` constructor, or used as the + ``session_factory`` argument of the :meth:`pyramid.config.Configurator.set_session_factory` method. The session factory returned by this function will create sessions @@ -355,6 +357,7 @@ def BaseCookieSessionFactory( return CookieSession + def UnencryptedCookieSessionFactoryConfig( secret, timeout=1200, @@ -369,6 +372,9 @@ def UnencryptedCookieSessionFactoryConfig( signed_deserialize=signed_deserialize, ): """ + .. deprecated:: 1.5 + Use :func:`pyramid.session.SignedCookieSessionFactory` instead. + Configure a :term:`session factory` which will provide unencrypted (but signed) cookie-based sessions. The return value of this function is a :term:`session factory`, which may be provided as @@ -424,14 +430,6 @@ def UnencryptedCookieSessionFactoryConfig( is valid. Default: ``signed_deserialize`` (using pickle). """ - warnings.warn( - ('The UnencryptedCookieSessionFactoryConfig is deprecated as of ' - 'Pyramid 1.5, and will be replaced by the ' - 'SignedCookieSessionFactory in future versions.'), - DeprecationWarning, - stacklevel=2 - ) - return BaseCookieSessionFactory( lambda v: signed_serialize(v, secret), lambda v: signed_deserialize(v, secret), @@ -446,6 +444,12 @@ def UnencryptedCookieSessionFactoryConfig( set_on_exception=cookie_on_exception, ) +deprecated( + 'UnencryptedCookieSessionFactoryConfig', + 'The UnencryptedCookieSessionFactoryConfig callable is deprecated as of ' + 'Pyramid 1.5. Use ``pyramid.session.SignedCookieSessionFactory`` instead.' + ) + def SignedCookieSessionFactory( secret, cookie_name='session', @@ -463,6 +467,8 @@ def SignedCookieSessionFactory( deserialize=None, ): """ + .. versionadded:: 1.5 + Configure a :term:`session factory` which will provide signed cookie-based sessions. The return value of this function is a :term:`session factory`, which may be provided as diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 382cf8eb5..eba123ce5 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -371,6 +371,16 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): self.assertEqual(session, {}) class TestUnencryptedCookieSession(SharedCookieSessionTests, unittest.TestCase): + def setUp(self): + super(TestUnencryptedCookieSession, self).setUp() + from zope.deprecation import __show__ + __show__.off() + + def tearDown(self): + super(TestUnencryptedCookieSession, self).tearDown() + from zope.deprecation import __show__ + __show__.on() + def _makeOne(self, request, **kw): from pyramid.session import UnencryptedCookieSessionFactoryConfig self._rename_cookie_var(kw, 'path', 'cookie_path') -- cgit v1.2.3 From e521f14cc4d986c2ad400abff3d6cb7ff784b775 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sat, 19 Oct 2013 15:57:33 -0400 Subject: add admonishment against secret sharing --- docs/narr/security.rst | 28 ++++++++++++++++++++++++++++ pyramid/authentication.py | 4 +++- pyramid/session.py | 8 ++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 6517fedf8..9884bb1dc 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -669,3 +669,31 @@ following interface: After you do so, you can pass an instance of such a class into the :class:`~pyramid.config.Configurator.set_authorization_policy` method at configuration time to use it. + +.. _admonishment_against_secret_sharing: + +Admomishment Against Secret-Sharing +----------------------------------- + +A "secret" is required by various components of Pyramid. For example, the +:term:`authentication policy` below uses a secret value ``seekrit``:: + + authn_policy = AuthTktAuthenticationPolicy('seekrit', hashalg='sha512') + +A :term:`session factory` also requires a secret:: + + my_session_factory = SignedCookieSessionFactory('itsaseekreet') + +It is tempting to use the same secret for multiple Pyramid subsystems. For +example, you might be tempted to use the value ``seekrit`` as the secret for +both the authentication policy and the session factory defined above. This is +a bad idea, because in both cases, these secrets are used to sign the payload +of the data. + +If you use the same secret for two different parts of your application for +signing purposes, it may allow an attacker to get his chosen plaintext signed, +which would allow the attacker to control the content of the payload. Re-using +a secret across two different subsystems might drop the security of signing to +zero. Keys should not be re-used across different contexts where an attacker +has the possibility of providing a chosen plaintext. + diff --git a/pyramid/authentication.py b/pyramid/authentication.py index 454ebd4b2..3c4077073 100644 --- a/pyramid/authentication.py +++ b/pyramid/authentication.py @@ -424,7 +424,9 @@ class AuthTktAuthenticationPolicy(CallbackAuthenticationPolicy): ``secret`` - The secret (a string) used for auth_tkt cookie signing. + The secret (a string) used for auth_tkt cookie signing. This value + should be unique across all values provided to Pyramid for various + subsystem secrets (see :ref:`admonishment_against_secret_sharing`). Required. ``callback`` diff --git a/pyramid/session.py b/pyramid/session.py index 60a5f7a63..f14783adb 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -487,7 +487,9 @@ def SignedCookieSessionFactory( ``secret`` A string which is used to sign the cookie. The secret should be at least as long as the block size of the selected hash algorithm. For - ``sha512`` this would mean a 128 bit (64 character) secret. + ``sha512`` this would mean a 128 bit (64 character) secret. It should + be unique within the set of secret values provided to Pyramid for + its various subsystems (see :ref:`admonishment_against_secret_sharing`). ``hashalg`` The HMAC digest algorithm to use for signing. The algorithm must be @@ -496,7 +498,9 @@ def SignedCookieSessionFactory( ``salt`` A namespace to avoid collisions between different uses of a shared secret. Reusing a secret for different parts of an application is - strongly discouraged. Default: ``'pyramid.session.'``. + strongly discouraged, see (see + :ref:`admonishment_against_secret_sharing`). Default: + ``'pyramid.session.'``. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. -- cgit v1.2.3 From 94360dffe85332733f35f2fb3ab32de3fedd787e Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sat, 19 Oct 2013 16:00:40 -0400 Subject: mon --- docs/narr/security.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 9884bb1dc..e85ed823a 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -672,7 +672,7 @@ configuration time to use it. .. _admonishment_against_secret_sharing: -Admomishment Against Secret-Sharing +Admonishment Against Secret-Sharing ----------------------------------- A "secret" is required by various components of Pyramid. For example, the -- cgit v1.2.3 From 7c756b9ace5c858e78d0ba6baccb5af2bd17a2df Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 15:10:26 -0500 Subject: remove redundant "see" --- pyramid/session.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index f14783adb..7224bef1a 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -498,9 +498,8 @@ def SignedCookieSessionFactory( ``salt`` A namespace to avoid collisions between different uses of a shared secret. Reusing a secret for different parts of an application is - strongly discouraged, see (see - :ref:`admonishment_against_secret_sharing`). Default: - ``'pyramid.session.'``. + strongly discouraged, (see :ref:`admonishment_against_secret_sharing`). + Default: ``'pyramid.session.'``. ``cookie_name`` The name of the cookie used for sessioning. Default: ``'session'``. -- cgit v1.2.3 From 2dea188aefc75837fabe76ae53e6a79d3e16f946 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 15:10:55 -0500 Subject: moar typos --- pyramid/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyramid/session.py b/pyramid/session.py index 7224bef1a..9ff9ffa20 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -498,7 +498,7 @@ def SignedCookieSessionFactory( ``salt`` A namespace to avoid collisions between different uses of a shared secret. Reusing a secret for different parts of an application is - strongly discouraged, (see :ref:`admonishment_against_secret_sharing`). + strongly discouraged (see :ref:`admonishment_against_secret_sharing`). Default: ``'pyramid.session.'``. ``cookie_name`` -- cgit v1.2.3 From dc491c48cd313c7d92e141ea91d8904f635c71b5 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sat, 19 Oct 2013 15:20:21 -0500 Subject: remove unnecessary length check, slices are magic --- pyramid/session.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyramid/session.py b/pyramid/session.py index 9ff9ffa20..2471d94ad 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -581,11 +581,7 @@ def SignedCookieSessionFactory( except (binascii.Error, TypeError) as e: raise ValueError('Badly formed base64 data: %s' % e) - cstruct_size = len(fstruct) - digest_size - if cstruct_size < 0: - raise ValueError('Input is too short.') - - cstruct = fstruct[:cstruct_size] + cstruct = fstruct[:-digest_size] expected_sig = fstruct[-digest_size:] sig = hmac.new(salted_secret, cstruct, digestmod).digest() -- cgit v1.2.3 From d79087c78c273eec3118a23243b9b93d353b09f2 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sun, 20 Oct 2013 13:54:16 -0400 Subject: rewording about deprecation and cookie compatibility --- CHANGES.txt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 80bd78808..6fdc08398 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -44,6 +44,8 @@ Bug Fixes allowing traversal to continue. See https://github.com/Pylons/pyramid/issues/1104 +- Remove unused ``renderer`` argument from ``Configurator.add_route``. + Documentation ------------- @@ -65,11 +67,13 @@ Backwards Incompatibilities Deprecations ------------ -- The ``UnencryptedCookieSessionFactoryConfig`` has been deprecated and is - superceded by the ``SignedCookieSessionFactory``. Cookies generated by - the two factories are not compatible. - See https://github.com/Pylons/pyramid/pull/1142 -- Remove unused ``renderer`` argument from ``Configurator.add_route``. +- The ``pyramid.session.UnencryptedCookieSessionFactoryConfig`` API has been + deprecated and is superseded by the + ``pyramid.session.SignedCookieSessionFactory``. Note that while the cookies + generated by the ``UnencryptedCookieSessionFactoryConfig`` + are compatible with cookies generated by old releases, cookies generated by + the SignedCookieSessionFactory are not. See + https://github.com/Pylons/pyramid/pull/1142 1.5a2 (2013-09-22) ================== -- cgit v1.2.3 From 2edbe1b61c7ace0a13f0d7242f333982a6fc9fde Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sun, 20 Oct 2013 16:20:23 -0400 Subject: add a note so we can defend the choice later --- pyramid/authentication.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyramid/authentication.py b/pyramid/authentication.py index ec8ac0a41..2c301bd29 100644 --- a/pyramid/authentication.py +++ b/pyramid/authentication.py @@ -1184,6 +1184,8 @@ class BasicAuthAuthenticationPolicy(CallbackAuthenticationPolicy): except (TypeError, binascii.Error): # can't decode return None + # try utf-8 first, then latin-1; see discussion in + # https://github.com/Pylons/pyramid/issues/898 try: auth = authbytes.decode('utf-8') except UnicodeDecodeError: -- cgit v1.2.3 From 7c96246eb6e14f5cd9414ddd61de089fe1f073d1 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Sun, 20 Oct 2013 21:13:49 -0500 Subject: notfound and forbidden decorators were ignoring view_defaults This could be fixed in other ways but the basic problem is that because config.add_notfound_view and config.add_forbidden_view have actual signatures instead of *args, **kwargs, the arguments are squashing the view_defaults which are applied later on the call to config.add_view. Basically, by the time the args get to config.add_view, they look explicit when they are not. fix #1173 --- pyramid/config/views.py | 2 ++ pyramid/tests/test_config/test_views.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 233bbac12..69f68e422 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -1550,6 +1550,7 @@ class ViewsConfiguratorMixin(object): return deriver(view) + @viewdefaults @action_method def add_forbidden_view( self, @@ -1629,6 +1630,7 @@ class ViewsConfiguratorMixin(object): set_forbidden_view = add_forbidden_view # deprecated sorta-bw-compat alias + @viewdefaults @action_method def add_notfound_view( self, diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 4924fff57..051961d25 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -1815,6 +1815,36 @@ class TestViewsConfigurationMixin(unittest.TestCase): self.assertRaises(ConfigurationError, config.add_forbidden_view, http_cache='foo') + def test_add_forbidden_view_with_view_defaults(self): + from pyramid.interfaces import IRequest + from pyramid.renderers import null_renderer + from pyramid.exceptions import PredicateMismatch + from pyramid.httpexceptions import HTTPForbidden + from zope.interface import directlyProvides + from zope.interface import implementedBy + class view(object): + __view_defaults__ = { + 'containment':'pyramid.tests.test_config.IDummy' + } + def __init__(self, request): + pass + def __call__(self): + return 'OK' + config = self._makeOne(autocommit=True) + config.add_forbidden_view( + view=view, + renderer=null_renderer) + wrapper = self._getViewCallable( + config, ctx_iface=implementedBy(HTTPForbidden), + request_iface=IRequest) + context = DummyContext() + directlyProvides(context, IDummy) + request = self._makeRequest(config) + self.assertEqual(wrapper(context, request), 'OK') + context = DummyContext() + request = self._makeRequest(config) + self.assertRaises(PredicateMismatch, wrapper, context, request) + def test_add_notfound_view(self): from pyramid.renderers import null_renderer from zope.interface import implementedBy @@ -1882,6 +1912,36 @@ class TestViewsConfigurationMixin(unittest.TestCase): result = view(None, request) self.assertEqual(result.location, '/scriptname/foo/?a=1&b=2') + def test_add_notfound_view_with_view_defaults(self): + from pyramid.interfaces import IRequest + from pyramid.renderers import null_renderer + from pyramid.exceptions import PredicateMismatch + from pyramid.httpexceptions import HTTPNotFound + from zope.interface import directlyProvides + from zope.interface import implementedBy + class view(object): + __view_defaults__ = { + 'containment':'pyramid.tests.test_config.IDummy' + } + def __init__(self, request): + pass + def __call__(self): + return 'OK' + config = self._makeOne(autocommit=True) + config.add_notfound_view( + view=view, + renderer=null_renderer) + wrapper = self._getViewCallable( + config, ctx_iface=implementedBy(HTTPNotFound), + request_iface=IRequest) + context = DummyContext() + directlyProvides(context, IDummy) + request = self._makeRequest(config) + self.assertEqual(wrapper(context, request), 'OK') + context = DummyContext() + request = self._makeRequest(config) + self.assertRaises(PredicateMismatch, wrapper, context, request) + # Since Python 3 has to be all cool and fancy and different... def _assertBody(self, response, value): from pyramid.compat import text_type -- cgit v1.2.3 From 64223904fd6330eb9e528311799cc4dd10e9daf1 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Tue, 22 Oct 2013 22:11:13 -0500 Subject: update changelog --- CHANGES.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.txt b/CHANGES.txt index 203db1a44..895dc572f 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -57,6 +57,10 @@ Bug Fixes to decode with UTF-8 first, and will fallback to Latin-1. See https://github.com/Pylons/pyramid/pull/1170 +- The ``@view_defaults`` now apply to notfound and forbidden views + that are defined as methods of a decorated class. + See https://github.com/Pylons/pyramid/issues/1173 + Documentation ------------- -- cgit v1.2.3 From 1c0db5f78473bed04dd9aa972fe53c683a02d8eb Mon Sep 17 00:00:00 2001 From: Bert JW Regeer Date: Sat, 26 Oct 2013 21:06:50 -0600 Subject: digestmod() has to accept a parameter in certain cases Due to line 69 in hmac.py in the Python standard library (2.7) it expects to be able to call the digestmod function with the current key if the key passed in exceeds the block size in length. This fixes the code so that digestmod can accept string as an extra parameter, which is passed through to hashlib.new() [1]: http://hg.python.org/cpython/file/2.7/Lib/hmac.py#l69 --- pyramid/session.py | 2 +- pyramid/tests/test_session.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pyramid/session.py b/pyramid/session.py index 9e0733661..d3a4113b9 100644 --- a/pyramid/session.py +++ b/pyramid/session.py @@ -565,7 +565,7 @@ def SignedCookieSessionFactory( if deserialize is None: deserialize = pickle.loads - digestmod = lambda: hashlib.new(hashalg) + digestmod = lambda string=b'': hashlib.new(hashalg, string) digest_size = digestmod().digest_size salted_secret = bytes_(salt or '') + bytes_(secret) diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index c13d3ce5c..048bf2c01 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -370,6 +370,24 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): session = self._makeOne(request) self.assertEqual(session, {}) + def test_very_long_key(self): + verylongkey = b'a' * 1024 + import webob + request = testing.DummyRequest() + session = self._makeOne(request, secret=verylongkey) + session['a'] = 1 + callbacks = request.response_callbacks + self.assertEqual(len(callbacks), 1) + response = webob.Response() + + try: + result = callbacks[0](request, response) + except TypeError as e: + self.fail('HMAC failed to initialize due to key length.') + + self.assertEqual(result, None) + self.assertTrue('Set-Cookie' in dict(response.headerlist)) + class TestUnencryptedCookieSession(SharedCookieSessionTests, unittest.TestCase): def setUp(self): super(TestUnencryptedCookieSession, self).setUp() -- cgit v1.2.3 From 5d1f9cbf40309548edb445512f2f950a6d207354 Mon Sep 17 00:00:00 2001 From: Bert JW Regeer Date: Sat, 26 Oct 2013 21:27:04 -0600 Subject: Bring coverage back to 100% --- pyramid/tests/test_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyramid/tests/test_session.py b/pyramid/tests/test_session.py index 048bf2c01..a9f70d6a0 100644 --- a/pyramid/tests/test_session.py +++ b/pyramid/tests/test_session.py @@ -382,7 +382,7 @@ class TestSignedCookieSession(SharedCookieSessionTests, unittest.TestCase): try: result = callbacks[0](request, response) - except TypeError as e: + except TypeError as e: # pragma: no cover self.fail('HMAC failed to initialize due to key length.') self.assertEqual(result, None) -- cgit v1.2.3 From 3c2f95e8049bbd45b144d454daa68005361828b2 Mon Sep 17 00:00:00 2001 From: Matt Russell Date: Thu, 24 Oct 2013 23:52:42 +0100 Subject: Security APIs on pyramid.request.Request The pyramid.security Authorization API function has_permission is made available on the request. The pyramid.security Authentication API functions are now available as properties (unauthenticated_userid, authenticated_userid, effective_principals) and methods (remember_userid, forget_userid) on pyramid.request.Request. Backwards compatibility: For each of the APIs moved to request method or property, the original API in the pyramid.security module proxies to the request. Reworked tests to check module level b/c wrappers call through to mixins for each API. Tests that check no reg on request now do the right thing. Use a response callback to set the request headers for forget_userid and remember_userid. Update docs. Attempt to improve a documentation section referencing the pyramid.security.has_permission function in docs/narr/resources.rst Ensures backwards compatiblity for `pyramid.security.forget` and `pyramid.security.remember`. --- CHANGES.txt | 8 + CONTRIBUTORS.txt | 2 + docs/narr/resources.rst | 12 +- docs/narr/security.rst | 4 +- docs/narr/testing.rst | 2 +- docs/narr/viewconfig.rst | 2 +- docs/tutorials/wiki/authorization.rst | 4 +- docs/tutorials/wiki2/authorization.rst | 4 +- pyramid/config/routes.py | 2 +- pyramid/config/testing.py | 10 +- pyramid/config/views.py | 2 +- pyramid/request.py | 10 +- pyramid/security.py | 291 +++++++++++++---------- pyramid/testing.py | 11 +- pyramid/tests/test_config/test_testing.py | 33 ++- pyramid/tests/test_request.py | 12 +- pyramid/tests/test_security.py | 369 +++++++++++++++++------------- 17 files changed, 465 insertions(+), 313 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 895dc572f..61f3b63f7 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,6 +4,14 @@ Unreleased Features -------- +- The :mod:``pyramid.security`` authentication API methods should now be + accessed via the request. The ``pyramid.security`` authoriztion API function + :meth:`has_permission` should now be accessed via the request. + The methods :meth:``pyramid.request.Request.forget_userid``, + meth:``pyramid.request.Request.remember_userid`` now automatically + set the headers on the response, as returned by the corrosponding + method of the current request's :term:``authentication policy``. + - Pyramid's console scripts (``pserve``, ``pviews``, etc) can now be run directly, allowing custom arguments to be sent to the python interpreter at runtime. For example:: diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index bfe22e540..6dba1076e 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -224,3 +224,5 @@ Contributors - Doug Hellmann, 2013/09/06 - Karl O. Pinc, 2013/09/27 + +- Matthew Russell, 2013/10/14 diff --git a/docs/narr/resources.rst b/docs/narr/resources.rst index b1bb611e5..34d75f2cc 100644 --- a/docs/narr/resources.rst +++ b/docs/narr/resources.rst @@ -201,7 +201,7 @@ location-aware resources. These APIs include (but are not limited to) :func:`~pyramid.traversal.resource_path`, :func:`~pyramid.traversal.resource_path_tuple`, or :func:`~pyramid.traversal.traverse`, :func:`~pyramid.traversal.virtual_root`, -and (usually) :func:`~pyramid.security.has_permission` and +and (usually) :meth:`~pyramid.request.Request.has_permission` and :func:`~pyramid.security.principals_allowed_by_permission`. In general, since so much :app:`Pyramid` infrastructure depends on @@ -695,10 +695,10 @@ The APIs provided by :ref:`location_module` are used against resources. These can be used to walk down a resource tree, or conveniently locate one resource "inside" another. -Some APIs in :ref:`security_module` accept a resource object as a parameter. -For example, the :func:`~pyramid.security.has_permission` API accepts a +Some APIs on the :class:`pyramid.request.Request` accept a resource object as a parameter. +For example, the :meth:`~pyramid.request.Request.has_permission` API accepts a resource object as one of its arguments; the ACL is obtained from this -resource or one of its ancestors. Other APIs in the :mod:`pyramid.security` -module also accept :term:`context` as an argument, and a context is always a -resource. +resource or one of its ancestors. Other security related APIs on the +:class:`pyramid.request.Request` class also accept :term:`context` as an argument, +and a context is always a resource. diff --git a/docs/narr/security.rst b/docs/narr/security.rst index e85ed823a..9e6fb6c82 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -550,7 +550,7 @@ also contain security debugging information in its body. Debugging Imperative Authorization Failures ------------------------------------------- -The :func:`pyramid.security.has_permission` API is used to check +The :meth:`pyramid.request.Request.has_permission` API is used to check security within view functions imperatively. It returns instances of objects that are effectively booleans. But these objects are not raw ``True`` or ``False`` objects, and have information attached to them @@ -563,7 +563,7 @@ one of :data:`pyramid.security.ACLAllowed`, ``msg`` attribute, which is a string indicating why the permission was denied or allowed. Introspecting this information in the debugger or via print statements when a call to -:func:`~pyramid.security.has_permission` fails is often useful. +:meth:`~pyramid.request.Request.has_permission` fails is often useful. .. index:: single: authentication policy (creating) diff --git a/docs/narr/testing.rst b/docs/narr/testing.rst index 88d6904c7..3f5d5ae6c 100644 --- a/docs/narr/testing.rst +++ b/docs/narr/testing.rst @@ -229,7 +229,7 @@ function. otherwise it would fail when run normally. Without doing anything special during a unit test, the call to -:func:`~pyramid.security.has_permission` in this view function will always +:meth:`~pyramid.request.Request.has_permission` in this view function will always return a ``True`` value. When a :app:`Pyramid` application starts normally, it will populate a :term:`application registry` using :term:`configuration declaration` calls made against a :term:`Configurator`. But if this diff --git a/docs/narr/viewconfig.rst b/docs/narr/viewconfig.rst index 7c76116f7..e5a2c1ade 100644 --- a/docs/narr/viewconfig.rst +++ b/docs/narr/viewconfig.rst @@ -435,7 +435,7 @@ configured view. If specified, this value should be a :term:`principal` identifier or a sequence of principal identifiers. If the - :func:`pyramid.security.effective_principals` method indicates that every + :meth:`pyramid.request.Request.effective_principals` method indicates that every principal named in the argument list is present in the current request, this predicate will return True; otherwise it will return False. For example: ``effective_principals=pyramid.security.Authenticated`` or diff --git a/docs/tutorials/wiki/authorization.rst b/docs/tutorials/wiki/authorization.rst index 460a852e0..2bd8c1f1c 100644 --- a/docs/tutorials/wiki/authorization.rst +++ b/docs/tutorials/wiki/authorization.rst @@ -207,8 +207,8 @@ need to be added.) :meth:`~pyramid.view.forbidden_view_config` will be used to customize the default 403 Forbidden page. -:meth:`~pyramid.security.remember` and -:meth:`~pyramid.security.forget` help to create and +:meth:`~pyramid.request.Request.remember_userid` and +:meth:`~pyramid.request.Request.forget_userid` help to create and expire an auth ticket cookie. Now add the ``login`` and ``logout`` views: diff --git a/docs/tutorials/wiki2/authorization.rst b/docs/tutorials/wiki2/authorization.rst index cf20db6d7..2b4263610 100644 --- a/docs/tutorials/wiki2/authorization.rst +++ b/docs/tutorials/wiki2/authorization.rst @@ -230,8 +230,8 @@ head of ``tutorial/tutorial/views.py``: :meth:`~pyramid.view.forbidden_view_config` will be used to customize the default 403 Forbidden page. -:meth:`~pyramid.security.remember` and -:meth:`~pyramid.security.forget` help to create and +:meth:`~pyramid.request.Request.remember_userid` and +:meth:`~pyramid.request.Request.forget_userid` help to create and expire an auth ticket cookie. Now add the ``login`` and ``logout`` views: diff --git a/pyramid/config/routes.py b/pyramid/config/routes.py index 4de4663a8..5a671c819 100644 --- a/pyramid/config/routes.py +++ b/pyramid/config/routes.py @@ -237,7 +237,7 @@ class RoutesConfiguratorMixin(object): If specified, this value should be a :term:`principal` identifier or a sequence of principal identifiers. If the - :func:`pyramid.security.effective_principals` method indicates that + :meth:`pyramid.request.Request.effective_principals` method indicates that every principal named in the argument list is present in the current request, this predicate will return True; otherwise it will return False. For example: diff --git a/pyramid/config/testing.py b/pyramid/config/testing.py index 2ab85b1f5..a006c4767 100644 --- a/pyramid/config/testing.py +++ b/pyramid/config/testing.py @@ -47,14 +47,14 @@ class TestingConfiguratorMixin(object): ``groupids`` argument. The authentication policy will return the userid identifier implied by the ``userid`` argument and the group ids implied by the ``groupids`` argument when the - :func:`pyramid.security.authenticated_userid` or - :func:`pyramid.security.effective_principals` APIs are + :meth:`pyramid.request.Request.authenticated_userid` or + :meth:`pyramid.request.Request.effective_principals` APIs are used. This function is most useful when testing code that uses - the APIs named :func:`pyramid.security.has_permission`, - :func:`pyramid.security.authenticated_userid`, - :func:`pyramid.security.effective_principals`, and + the APIs named :meth:`pyramid.request.Request.has_permission`, + :meth:`pyramid.request.Request.authenticated_userid`, + :meth:`pyramid.request.Request.effective_principals`, and :func:`pyramid.security.principals_allowed_by_permission`. .. versionadded:: 1.4 diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 69f68e422..b0cd785f5 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -1017,7 +1017,7 @@ class ViewsConfiguratorMixin(object): If specified, this value should be a :term:`principal` identifier or a sequence of principal identifiers. If the - :func:`pyramid.security.effective_principals` method indicates that + :meth:`pyramid.request.Request.effective_principals` method indicates that every principal named in the argument list is present in the current request, this predicate will return True; otherwise it will return False. For example: diff --git a/pyramid/request.py b/pyramid/request.py index 2cf0613f7..da640ea7d 100644 --- a/pyramid/request.py +++ b/pyramid/request.py @@ -21,6 +21,7 @@ from pyramid.compat import ( from pyramid.decorator import reify from pyramid.i18n import LocalizerRequestMixin from pyramid.response import Response +from pyramid.security import AuthenticationAPIMixin, AuthorizationAPIMixin from pyramid.url import URLMethodsMixin from pyramid.util import InstancePropertyMixin @@ -136,8 +137,13 @@ class CallbackMethodsMixin(object): callback(self) @implementer(IRequest) -class Request(BaseRequest, URLMethodsMixin, CallbackMethodsMixin, - InstancePropertyMixin, LocalizerRequestMixin): +class Request(BaseRequest, + URLMethodsMixin, + CallbackMethodsMixin, + InstancePropertyMixin, + LocalizerRequestMixin, + AuthenticationAPIMixin, + AuthorizationAPIMixin): """ A subclass of the :term:`WebOb` Request class. An instance of this class is created by the :term:`router` and is provided to a diff --git a/pyramid/security.py b/pyramid/security.py index 3e25f9b2f..b5e0a2c78 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -30,79 +30,64 @@ DENY_ALL = (Deny, Everyone, ALL_PERMISSIONS) NO_PERMISSION_REQUIRED = '__no_permission_required__' -def has_permission(permission, context, request): - """ Provided a permission (a string or unicode object), a context - (a :term:`resource` instance) and a request object, return an - instance of :data:`pyramid.security.Allowed` if the permission - is granted in this context to the user implied by the - request. Return an instance of :mod:`pyramid.security.Denied` - if this permission is not granted in this context to this user. - This function delegates to the current authentication and - authorization policies. Return - :data:`pyramid.security.Allowed` unconditionally if no - authentication policy has been configured in this application.""" +def _get_registry(request): try: reg = request.registry except AttributeError: reg = get_current_registry() # b/c - authn_policy = reg.queryUtility(IAuthenticationPolicy) - if authn_policy is None: - return Allowed('No authentication policy in use.') + return reg + +# b/c +def has_permission(permission, context, request): + """ Backwards compatible wrapper. - authz_policy = reg.queryUtility(IAuthorizationPolicy) - if authz_policy is None: - raise ValueError('Authentication policy registered without ' - 'authorization policy') # should never happen - principals = authn_policy.effective_principals(request) - return authz_policy.permits(context, principals, permission) + Delegates to the :meth:``pyramid.request.Request.has_permission`` method. + """ + return request.has_permission(permission, context) +# b/c def authenticated_userid(request): - """ Return the userid of the currently authenticated user or - ``None`` if there is no :term:`authentication policy` in effect or - there is no currently authenticated user.""" - try: - reg = request.registry - except AttributeError: - reg = get_current_registry() # b/c + """ Backwards compatible wrapper. - policy = reg.queryUtility(IAuthenticationPolicy) - if policy is None: - return None - return policy.authenticated_userid(request) + Delegates to the + :meth:``pyramid.request.Request.authenticated_userid`` method. + """ + return request.authenticated_userid +# b/c def unauthenticated_userid(request): - """ Return an object which represents the *claimed* (not verified) user - id of the credentials present in the request. ``None`` if there is no - :term:`authentication policy` in effect or there is no user data - associated with the current request. This differs from - :func:`~pyramid.security.authenticated_userid`, because the effective - authentication policy will not ensure that a record associated with the - userid exists in persistent storage.""" - try: - reg = request.registry - except AttributeError: - reg = get_current_registry() # b/c + """ Backwards compatible wrapper. - policy = reg.queryUtility(IAuthenticationPolicy) - if policy is None: - return None - return policy.unauthenticated_userid(request) + Delegates to the + :meth:``pyramid.request.Request.unauthenticated_userid`` method. + """ + return request.unauthenticated_userid +# b/c def effective_principals(request): - """ Return the list of 'effective' :term:`principal` identifiers - for the ``request``. This will include the userid of the - currently authenticated user if a user is currently - authenticated. If no :term:`authentication policy` is in effect, - this will return an empty sequence.""" - try: - reg = request.registry - except AttributeError: - reg = get_current_registry() # b/c + """ Backwards compatible wrapper. + + Delegates to the + :meth:``pyramid.request.Request.effective_principals`` method. + """ + return request.effective_principals + +# b/c +def remember(request, principal, **kw): + """ Backwards compatible wrapper. + + Delegates to the :meth:``pyramid.request.Request.remember_userid`` method. + """ + return request._remember_userid(principal, **kw) + +# b/c +def forget(request): + """ Backwards compatible wrapper. + + Delegates to the :meth:``pyramid.request.Request.forget_userid`` method. + """ + return request._forget_userid() - policy = reg.queryUtility(IAuthenticationPolicy) - if policy is None: - return [Everyone] - return policy.effective_principals(request) def principals_allowed_by_permission(context, permission): """ Provided a ``context`` (a resource object), and a ``permission`` @@ -140,10 +125,7 @@ def view_execution_permitted(context, request, name=''): An exception is raised if no view is found. """ - try: - reg = request.registry - except AttributeError: - reg = get_current_registry() # b/c + reg = _get_registry(request) provides = [IViewClassifier] + map_(providedBy, (request, context)) view = reg.adapters.lookup(provides, ISecuredView, name=name) if view is None: @@ -157,58 +139,6 @@ def view_execution_permitted(context, request, name=''): (name, context)) return view.__permitted__(context, request) -def remember(request, principal, **kw): - """ Return a sequence of header tuples (e.g. ``[('Set-Cookie', - 'foo=abc')]``) suitable for 'remembering' a set of credentials - implied by the data passed as ``principal`` and ``*kw`` using the - current :term:`authentication policy`. Common usage might look - like so within the body of a view function (``response`` is - assumed to be a :term:`WebOb` -style :term:`response` object - computed previously by the view code):: - - from pyramid.security import remember - headers = remember(request, 'chrism', password='123', max_age='86400') - response.headerlist.extend(headers) - return response - - If no :term:`authentication policy` is in use, this function will - always return an empty sequence. If used, the composition and - meaning of ``**kw`` must be agreed upon by the calling code and - the effective authentication policy.""" - try: - reg = request.registry - except AttributeError: - reg = get_current_registry() # b/c - policy = reg.queryUtility(IAuthenticationPolicy) - if policy is None: - return [] - else: - return policy.remember(request, principal, **kw) - -def forget(request): - """ Return a sequence of header tuples (e.g. ``[('Set-Cookie', - 'foo=abc')]``) suitable for 'forgetting' the set of credentials - possessed by the currently authenticated user. A common usage - might look like so within the body of a view function - (``response`` is assumed to be an :term:`WebOb` -style - :term:`response` object computed previously by the view code):: - - from pyramid.security import forget - headers = forget(request) - response.headerlist.extend(headers) - return response - - If no :term:`authentication policy` is in use, this function will - always return an empty sequence.""" - try: - reg = request.registry - except AttributeError: - reg = get_current_registry() # b/c - policy = reg.queryUtility(IAuthenticationPolicy) - if policy is None: - return [] - else: - return policy.forget(request) class PermitsResult(int): def __new__(cls, s, *args): @@ -294,3 +224,134 @@ class ACLAllowed(ACLPermitsResult): summary is available as the ``msg`` attribute.""" boolval = 1 +class AuthenticationAPIMixin(object): + + def _get_authentication_policy(self): + reg = _get_registry(self) + return reg.queryUtility(IAuthenticationPolicy) + + @property + def authenticated_userid(self): + """ Return the userid of the currently authenticated user or + ``None`` if there is no :term:`authentication policy` in effect or + there is no currently authenticated user.""" + policy = self._get_authentication_policy() + if policy is None: + return None + return policy.authenticated_userid(self) + + @property + def unauthenticated_userid(self): + """ Return an object which represents the *claimed* (not verified) user + id of the credentials present in the request. ``None`` if there is no + :term:`authentication policy` in effect or there is no user data + associated with the current request. This differs from + :func:`~pyramid.security.authenticated_userid`, because the effective + authentication policy will not ensure that a record associated with the + userid exists in persistent storage.""" + policy = self._get_authentication_policy() + if policy is None: + return None + return policy.unauthenticated_userid(self) + + @property + def effective_principals(self): + """ Return the list of 'effective' :term:`principal` identifiers + for the ``request``. This will include the userid of the + currently authenticated user if a user is currently + authenticated. If no :term:`authentication policy` is in effect, + this will return an empty sequence.""" + policy = self._get_authentication_policy() + if policy is None: + return [Everyone] + return policy.effective_principals(self) + + # b/c + def _remember_userid(self, principal, **kw): + policy = self._get_authentication_policy() + if policy is None: + return + return policy.remember(self, principal, **kw) + + def remember_userid(self, principal, **kw): + """ Sets a sequence of header tuples (e.g. ``[('Set-Cookie', + 'foo=abc')]``) on this request's response. + These headers are suitable for 'remembering' a set of credentials + implied by the data passed as ``principal`` and ``*kw`` using the + current :term:`authentication policy`. Common usage might look + like so within the body of a view function (``response`` is + assumed to be a :term:`WebOb` -style :term:`response` object + computed previously by the view code):: + + .. code-block:: python + + request.remember_userid('chrism', password='123', max_age='86400') + + If no :term:`authentication policy` is in use, this function will + do nothing. If used, the composition and + meaning of ``**kw`` must be agreed upon by the calling code and + the effective authentication policy.""" + headers = self._remember_userid(principal, **kw) + callback = lambda req, response: response.headerlist.extend(headers) + self.add_response_callback(callback) + + # b/c + def _forget_userid(self): + policy = self._get_authentication_policy() + if policy is None: + return + return policy.forget(self) + + def forget_userid(self): + """ Sets a sequence of header tuples (e.g. ``[('Set-Cookie', + 'foo=abc')]``) suitable for 'forgetting' the set of credentials + possessed by the currently authenticated user on the response. + A common usage might look like so within the body of a view function + (``response`` is assumed to be an :term:`WebOb` -style + :term:`response` object computed previously by the view code):: + + .. code-block:: python + + request.forget_userid() + + If no :term:`authentication policy` is in use, this function will + be a noop.""" + headers = self._forget_userid() + callback = lambda req, response: response.headerlist.extend(headers) + self.add_response_callback(callback) + +class AuthorizationAPIMixin(object): + + def has_permission(self, permission, context=None): + """ Given a permission and an optional context, + returns an instance of :data:`pyramid.security.Allowed if the + permission is granted to this request with the provided context, + or the context already associated with the request. Otherwise, + returns an instance of :data:`pyramid.security.Denied`. + This method delegates to the current authentication and + authorization policies. Returns :data:`pyramid.security.Allowed` + unconditionally if no authentication policy has been registered + for this request. + + .. versionchanged:: 1.5a3 + If context is None, then attempt to use the context attribute + of self, if not set then the AttributeError is propergated. + + :param permission: Does this request have the given permission? + :type permission: unicode, str + :param context: Typically a resource of a regsitered type. + :type context: object + :returns: `pyramid.security.PermitsResult` + """ + if context is None: + context = self.context + reg = _get_registry(self) + authn_policy = reg.queryUtility(IAuthenticationPolicy) + if authn_policy is None: + return Allowed('No authentication policy in use.') + authz_policy = reg.queryUtility(IAuthorizationPolicy) + if authz_policy is None: + raise ValueError('Authentication policy registered without ' + 'authorization policy') # should never happen + principals = authn_policy.effective_principals(self) + return authz_policy.permits(context, principals, permission) diff --git a/pyramid/testing.py b/pyramid/testing.py index 4590c55f8..2416c7b34 100644 --- a/pyramid/testing.py +++ b/pyramid/testing.py @@ -27,6 +27,8 @@ from pyramid.registry import Registry from pyramid.security import ( Authenticated, Everyone, + AuthenticationAPIMixin, + AuthorizationAPIMixin, ) from pyramid.threadlocal import ( @@ -280,10 +282,13 @@ class DummySession(dict): token = self.new_csrf_token() return token - @implementer(IRequest) -class DummyRequest(URLMethodsMixin, CallbackMethodsMixin, InstancePropertyMixin, - LocalizerRequestMixin): +class DummyRequest(URLMethodsMixin, + CallbackMethodsMixin, + InstancePropertyMixin, + LocalizerRequestMixin, + AuthenticationAPIMixin, + AuthorizationAPIMixin): """ A DummyRequest object (incompletely) imitates a :term:`request` object. The ``params``, ``environ``, ``headers``, ``path``, and diff --git a/pyramid/tests/test_config/test_testing.py b/pyramid/tests/test_config/test_testing.py index 1089f09fc..d13cb9285 100644 --- a/pyramid/tests/test_config/test_testing.py +++ b/pyramid/tests/test_config/test_testing.py @@ -1,6 +1,7 @@ import unittest from pyramid.compat import text_ +from pyramid.security import AuthenticationAPIMixin, AuthorizationAPIMixin from pyramid.tests.test_config import IDummy class TestingConfiguratorMixinTests(unittest.TestCase): @@ -24,28 +25,31 @@ class TestingConfiguratorMixinTests(unittest.TestCase): self.assertEqual(ut.permissive, False) def test_testing_securitypolicy_remember_result(self): - from pyramid.security import remember config = self._makeOne(autocommit=True) pol = config.testing_securitypolicy( 'user', ('group1', 'group2'), - permissive=False, remember_result=True) + permissive=False, + remember_result=[('X-Pyramid-Test', True)]) request = DummyRequest() request.registry = config.registry - val = remember(request, 'fred') + request.remember_userid('fred') self.assertEqual(pol.remembered, 'fred') + val = dict(request.response.headerlist).get('X-Pyramid-Test') self.assertEqual(val, True) def test_testing_securitypolicy_forget_result(self): - from pyramid.security import forget config = self._makeOne(autocommit=True) pol = config.testing_securitypolicy( 'user', ('group1', 'group2'), - permissive=False, forget_result=True) + permissive=False, + forget_result=[('X-Pyramid-Test', True)]) request = DummyRequest() request.registry = config.registry - val = forget(request) + request.response = DummyResponse() + request.forget_userid() self.assertEqual(pol.forgotten, True) - self.assertEqual(val, True) + val = dict(request.response.headerlist).get('X-Pyramid-Test') + self.assertTrue(val) def test_testing_resources(self): from pyramid.traversal import find_resource @@ -196,7 +200,15 @@ from zope.interface import implementer class DummyEvent: pass -class DummyRequest: +class DummyResponse(object): + def __init__(self): + self.headers = [] + + @property + def headerlist(self): + return self.headers + +class DummyRequest(AuthenticationAPIMixin, AuthorizationAPIMixin): subpath = () matchdict = None def __init__(self, environ=None): @@ -205,4 +217,7 @@ class DummyRequest: self.environ = environ self.params = {} self.cookies = {} - + self.response = DummyResponse() + + def add_response_callback(self, callback): + callback(self, self.response) diff --git a/pyramid/tests/test_request.py b/pyramid/tests/test_request.py index 6cd72fc59..ed41b62ff 100644 --- a/pyramid/tests/test_request.py +++ b/pyramid/tests/test_request.py @@ -6,9 +6,10 @@ from pyramid.compat import ( text_, bytes_, native_, - iteritems_, - iterkeys_, - itervalues_, + ) +from pyramid.security import ( + AuthenticationAPIMixin, + AuthorizationAPIMixin, ) class TestRequest(unittest.TestCase): @@ -53,6 +54,11 @@ class TestRequest(unittest.TestCase): cls = self._getTargetClass() self.assertEqual(cls.ResponseClass, Response) + def test_implements_security_apis(self): + apis = (AuthenticationAPIMixin, AuthorizationAPIMixin) + r = self._makeOne() + self.assertTrue(isinstance(r, apis)) + def test_charset_defaults_to_utf8(self): r = self._makeOne({'PATH_INFO':'/'}) self.assertEqual(r.charset, 'UTF-8') diff --git a/pyramid/tests/test_security.py b/pyramid/tests/test_security.py index e530e33ca..4b40feaf3 100644 --- a/pyramid/tests/test_security.py +++ b/pyramid/tests/test_security.py @@ -1,7 +1,8 @@ import unittest -from pyramid.testing import cleanUp +from pyramid.testing import cleanUp, DummyRequest +_TEST_HEADER = 'X-Pyramid-Test' class TestAllPermissionsList(unittest.TestCase): def setUp(self): @@ -103,13 +104,38 @@ class TestACLDenied(unittest.TestCase): self.assertTrue('" % msg in repr(denied)) -class TestViewExecutionPermitted(unittest.TestCase): +class TestPrincipalsAllowedByPermission(unittest.TestCase): def setUp(self): cleanUp() def tearDown(self): cleanUp() + def _callFUT(self, *arg): + from pyramid.security import principals_allowed_by_permission + return principals_allowed_by_permission(*arg) + + def test_no_authorization_policy(self): + from pyramid.security import Everyone + context = DummyContext() + result = self._callFUT(context, 'view') + self.assertEqual(result, [Everyone]) + + def test_with_authorization_policy(self): + from pyramid.threadlocal import get_current_registry + registry = get_current_registry() + _registerAuthorizationPolicy(registry, 'yo') + context = DummyContext() + result = self._callFUT(context, 'view') + self.assertEqual(result, 'yo') + +class TestViewExecutionPermitted(unittest.TestCase): + def setUp(self): + cleanUp() + + def tearDown(self): + cleanUp() + def _callFUT(self, *arg, **kw): from pyramid.security import view_execution_permitted return view_execution_permitted(*arg, **kw) @@ -174,229 +200,258 @@ class TestViewExecutionPermitted(unittest.TestCase): request = DummyRequest({}) directlyProvides(request, IRequest) result = self._callFUT(context, request, '') - self.assertTrue(result is True) + self.assertTrue(result) -class TestHasPermission(unittest.TestCase): +class AuthenticationAPIMixinTest(object): def setUp(self): cleanUp() - - def tearDown(self): - cleanUp() - - def _callFUT(self, *arg): - from pyramid.security import has_permission - return has_permission(*arg) - - def test_no_authentication_policy(self): - request = _makeRequest() - result = self._callFUT('view', None, request) - self.assertEqual(result, True) - self.assertEqual(result.msg, 'No authentication policy in use.') - - def test_authentication_policy_no_authorization_policy(self): - request = _makeRequest() - _registerAuthenticationPolicy(request.registry, None) - self.assertRaises(ValueError, self._callFUT, 'view', None, request) - - def test_authn_and_authz_policies_registered(self): - request = _makeRequest() - _registerAuthenticationPolicy(request.registry, None) - _registerAuthorizationPolicy(request.registry, 'yo') - self.assertEqual(self._callFUT('view', None, request), 'yo') - def test_no_registry_on_request(self): - from pyramid.threadlocal import get_current_registry - request = DummyRequest({}) - registry = get_current_registry() - _registerAuthenticationPolicy(registry, None) - _registerAuthorizationPolicy(registry, 'yo') - self.assertEqual(self._callFUT('view', None, request), 'yo') - -class TestAuthenticatedUserId(unittest.TestCase): - def setUp(self): - cleanUp() - def tearDown(self): cleanUp() - def _callFUT(self, request): + def _makeOne(self): + from pyramid.registry import Registry + from pyramid.security import AuthenticationAPIMixin + request = DummyRequest(environ={}) + self.assertTrue(isinstance(request, AuthenticationAPIMixin)) + request.registry = Registry() + request.context = object() + return request + + def _makeFakeOne(self): + class FakeRequest(DummyRequest): + @property + def authenticated_userid(req): + return 'authenticated_userid' + + @property + def unauthenticated_userid(req): + return 'unauthenticated_userid' + + @property + def effective_principals(req): + return 'effective_principals' + + def _forget_userid(req): + return [('X-Pyramid-Test', 'forget_userid')] + + def _remember_userid(req, principal, **kw): + return [('X-Pyramid-Test', 'remember_userid')] + + return FakeRequest({}) + +class TestAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): + def test_backward_compat_delegates_to_mixin(self): + request = self._makeFakeOne() from pyramid.security import authenticated_userid - return authenticated_userid(request) + self.assertEqual(authenticated_userid(request), 'authenticated_userid') def test_no_authentication_policy(self): - request = _makeRequest() - result = self._callFUT(request) - self.assertEqual(result, None) + request = self._makeOne() + self.assertEqual(request.authenticated_userid, None) def test_with_authentication_policy(self): - request = _makeRequest() + request = self._makeOne() _registerAuthenticationPolicy(request.registry, 'yo') - result = self._callFUT(request) - self.assertEqual(result, 'yo') + self.assertEqual(request.authenticated_userid, 'yo') def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry - request = DummyRequest({}) registry = get_current_registry() + request = self._makeOne() + del request.registry _registerAuthenticationPolicy(registry, 'yo') - result = self._callFUT(request) - self.assertEqual(result, 'yo') + self.assertEqual(request.authenticated_userid, 'yo') -class TestUnauthenticatedUserId(unittest.TestCase): - def setUp(self): - cleanUp() - - def tearDown(self): - cleanUp() - - def _callFUT(self, request): +class TestUnAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): + def test_backward_compat_delegates_to_mixin(self): + request = self._makeFakeOne() from pyramid.security import unauthenticated_userid - return unauthenticated_userid(request) + self.assertEqual(unauthenticated_userid(request), + 'unauthenticated_userid') def test_no_authentication_policy(self): - request = _makeRequest() - result = self._callFUT(request) - self.assertEqual(result, None) + request = self._makeOne() + self.assertEqual(request.unauthenticated_userid, None) def test_with_authentication_policy(self): - request = _makeRequest() + request = self._makeOne() _registerAuthenticationPolicy(request.registry, 'yo') - result = self._callFUT(request) - self.assertEqual(result, 'yo') + self.assertEqual(request.unauthenticated_userid, 'yo') def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry - request = DummyRequest({}) registry = get_current_registry() + request = self._makeOne() + del request.registry _registerAuthenticationPolicy(registry, 'yo') - result = self._callFUT(request) - self.assertEqual(result, 'yo') + self.assertEqual(request.unauthenticated_userid, 'yo') -class TestEffectivePrincipals(unittest.TestCase): - def setUp(self): - cleanUp() - - def tearDown(self): - cleanUp() - - def _callFUT(self, request): +class TestEffectivePrincipals(AuthenticationAPIMixinTest, unittest.TestCase): + def test_backward_compat_delegates_to_mixin(self): + request = self._makeFakeOne() from pyramid.security import effective_principals - return effective_principals(request) + self.assertEqual(effective_principals(request), 'effective_principals') def test_no_authentication_policy(self): from pyramid.security import Everyone - request = _makeRequest() - result = self._callFUT(request) - self.assertEqual(result, [Everyone]) + request = self._makeOne() + self.assertEqual(request.effective_principals, [Everyone]) def test_with_authentication_policy(self): - request = _makeRequest() + request = self._makeOne() _registerAuthenticationPolicy(request.registry, 'yo') - result = self._callFUT(request) - self.assertEqual(result, 'yo') + self.assertEqual(request.effective_principals, 'yo') def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() - request = DummyRequest({}) + request = self._makeOne() + del request.registry _registerAuthenticationPolicy(registry, 'yo') - result = self._callFUT(request) - self.assertEqual(result, 'yo') + self.assertEqual(request.effective_principals, 'yo') -class TestPrincipalsAllowedByPermission(unittest.TestCase): - def setUp(self): - cleanUp() - - def tearDown(self): - cleanUp() +class ResponseCallbackTestMixin(AuthenticationAPIMixinTest): - def _callFUT(self, *arg): - from pyramid.security import principals_allowed_by_permission - return principals_allowed_by_permission(*arg) + def assert_response_headers_set(self, request): + request._process_response_callbacks(request.response) + headers = request.response.headerlist + self.assertTrue((_TEST_HEADER, self.principal) in headers, msg=headers) - def test_no_authorization_policy(self): - from pyramid.security import Everyone - context = DummyContext() - result = self._callFUT(context, 'view') - self.assertEqual(result, [Everyone]) +class TestRememberUserId(ResponseCallbackTestMixin, unittest.TestCase): + principal = 'the4th' - def test_with_authorization_policy(self): + def test_backward_compat_delegates_to_mixin(self): + request = self._makeFakeOne() + from pyramid.security import remember + self.assertEqual(remember(request, 'matt'), + [('X-Pyramid-Test', 'remember_userid')]) + + def test_with_no_authentication_policy(self): + request = self._makeOne() + headers_before = request.response.headers + request.remember_userid(self.principal) + self.assertEqual(headers_before, request.response.headers) + + def test_with_authentication_policy(self): + request = self._makeOne() + _registerAuthenticationPolicy(request.registry, self.principal) + request.remember_userid(self.principal) + self.assert_response_headers_set(request) + + def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() - _registerAuthorizationPolicy(registry, 'yo') - context = DummyContext() - result = self._callFUT(context, 'view') - self.assertEqual(result, 'yo') + request = self._makeOne() + del request.registry + _registerAuthenticationPolicy(registry, self.principal) + request.remember_userid(self.principal) + self.assert_response_headers_set(request) -class TestRemember(unittest.TestCase): - def setUp(self): - cleanUp() - - def tearDown(self): - cleanUp() +class TestForgetUserId(ResponseCallbackTestMixin, unittest.TestCase): + principal = 'me-not' - def _callFUT(self, *arg): - from pyramid.security import remember - return remember(*arg) + def _makeOne(self): + request = super(TestForgetUserId, self)._makeOne() + request.response.headers.add(_TEST_HEADER, self.principal) + return request - def test_no_authentication_policy(self): - request = _makeRequest() - result = self._callFUT(request, 'me') - self.assertEqual(result, []) + def test_backward_compat_delegates_to_mixin(self): + request = self._makeFakeOne() + from pyramid.security import forget + self.assertEqual(forget(request), + [('X-Pyramid-Test', 'forget_userid')]) + + def test_with_no_authentication_policy(self): + request = self._makeOne() + headers_before = request.response.headers + request.forget_userid() + self.assertEqual(headers_before, request.response.headers) def test_with_authentication_policy(self): - request = _makeRequest() - registry = request.registry - _registerAuthenticationPolicy(registry, 'yo') - result = self._callFUT(request, 'me') - self.assertEqual(result, 'yo') + request = self._makeOne() + policy = _registerAuthenticationPolicy(request.registry, self.principal) + policy._header_remembered = (_TEST_HEADER, self.principal) + request.forget_userid() + self.assert_response_headers_set(request) def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() - request = DummyRequest({}) - _registerAuthenticationPolicy(registry, 'yo') - result = self._callFUT(request, 'me') - self.assertEqual(result, 'yo') - -class TestForget(unittest.TestCase): + request = self._makeOne() + del request.registry + policy = _registerAuthenticationPolicy(registry, self.principal) + policy._header_remembered = (_TEST_HEADER, self.principal) + request.forget_userid() + self.assert_response_headers_set(request) + +class TestHasPermission(unittest.TestCase): def setUp(self): cleanUp() def tearDown(self): cleanUp() - def _callFUT(self, *arg): - from pyramid.security import forget - return forget(*arg) + def _makeOne(self): + from pyramid.security import AuthorizationAPIMixin + from pyramid.registry import Registry + mixin = AuthorizationAPIMixin() + mixin.registry = Registry() + mixin.context = object() + return mixin + + def test_delegates_to_mixin(self): + mixin = self._makeOne() + from pyramid.security import has_permission + self.called_has_permission = False + + def mocked_has_permission(*args, **kw): + self.called_has_permission = True + + mixin.has_permission = mocked_has_permission + has_permission('view', object(), mixin) + self.assertTrue(self.called_has_permission) def test_no_authentication_policy(self): - request = _makeRequest() - result = self._callFUT(request) - self.assertEqual(result, []) + request = self._makeOne() + result = request.has_permission('view') + self.assertTrue(result) + self.assertEqual(result.msg, 'No authentication policy in use.') - def test_with_authentication_policy(self): - request = _makeRequest() - _registerAuthenticationPolicy(request.registry, 'yo') - result = self._callFUT(request) - self.assertEqual(result, 'yo') + def test_with_no_authorization_policy(self): + request = self._makeOne() + _registerAuthenticationPolicy(request.registry, None) + self.assertRaises(ValueError, + request.has_permission, 'view', context=None) - def test_with_authentication_policy_no_reg_on_request(self): + def test_with_authn_and_authz_policies_registered(self): + request = self._makeOne() + _registerAuthenticationPolicy(request.registry, None) + _registerAuthorizationPolicy(request.registry, 'yo') + self.assertEqual(request.has_permission('view', context=None), 'yo') + + def test_with_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() - request = DummyRequest({}) - _registerAuthenticationPolicy(registry, 'yo') - result = self._callFUT(request) - self.assertEqual(result, 'yo') + request = self._makeOne() + del request.registry + _registerAuthenticationPolicy(registry, None) + _registerAuthorizationPolicy(registry, 'yo') + self.assertEqual(request.has_permission('view'), 'yo') + + def test_with_no_context_passed(self): + request = self._makeOne() + self.assertTrue(request.has_permission('view')) + + def test_with_no_context_passed_or_on_request(self): + request = self._makeOne() + del request.context + self.assertRaises(AttributeError, request.has_permission, 'view') class DummyContext: def __init__(self, *arg, **kw): self.__dict__.update(kw) -class DummyRequest: - def __init__(self, environ): - self.environ = environ - class DummyAuthenticationPolicy: def __init__(self, result): self.result = result @@ -411,10 +466,12 @@ class DummyAuthenticationPolicy: return self.result def remember(self, request, principal, **kw): - return self.result + headers = [(_TEST_HEADER, principal)] + self._header_remembered = headers[0] + return headers def forget(self, request): - return self.result + return [self._header_remembered] class DummyAuthorizationPolicy: def __init__(self, result): @@ -437,11 +494,3 @@ def _registerAuthorizationPolicy(reg, result): policy = DummyAuthorizationPolicy(result) reg.registerUtility(policy, IAuthorizationPolicy) return policy - -def _makeRequest(): - from pyramid.registry import Registry - request = DummyRequest({}) - request.registry = Registry() - return request - - -- cgit v1.2.3 From 0184b527725cfb634e4d57a1b033450fa8b24502 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 28 Oct 2013 15:26:31 -0400 Subject: Bring change log, API docs, and deprecations in line with normal policies/processes --- CHANGES.txt | 56 +++++++++++-- docs/api/request.rst | 47 ++++++++++- pyramid/config/routes.py | 8 +- pyramid/config/testing.py | 8 +- pyramid/config/views.py | 8 +- pyramid/request.py | 21 +++-- pyramid/security.py | 185 ++++++++++++++++++++++++++++++++--------- pyramid/testing.py | 14 ++-- pyramid/tests/test_security.py | 96 +++++++++++++++------ 9 files changed, 345 insertions(+), 98 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 61f3b63f7..20b7726c4 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,13 +4,34 @@ Unreleased Features -------- -- The :mod:``pyramid.security`` authentication API methods should now be - accessed via the request. The ``pyramid.security`` authoriztion API function - :meth:`has_permission` should now be accessed via the request. - The methods :meth:``pyramid.request.Request.forget_userid``, - meth:``pyramid.request.Request.remember_userid`` now automatically - set the headers on the response, as returned by the corrosponding - method of the current request's :term:``authentication policy``. +- Authentication and authorization APIs have been added as as methods of the + request: ``request.has_permission``, ``request.forget_userid``, and + ``request.remember_userid``. + + ``request.has_permission`` is a method-based alternative to the + ``pyramid.security.has_permission`` API and works exactly the same. The + older API is now deprecated. + + ``request.forget_userid`` and ``request.remember_userid`` are method-based + alternatives to ``pyramid.security.forget`` and + ``pyramid.security.remember``. These do not work exacly the same as their + function counterparts, however. These methods automatically set the headers + returned by the authentication policy on the response, whereas the older + function-based APIs returned a sequence of headers and required the caller to + set those headers. The older function-based API still works but is now + deprecated. + +- Property API attributes have been added to the request for easier access to + authentication data: ``request.authenticated_userid``, + ``request.unauthenticated_userid``, and ``request.effective_principals``. + + These are analogues, respectively, of + ``pyramid.security.authenticated_userid``, + ``pyramid.security.unauthenticated_userid``, and + ``pyramid.security.effective_principals``. They operate exactly the same, + except they are attributes of the request instead of functions accepting a + request. They are properties, so they cannot be assigned to. The older + function-based APIs are now deprecated. - Pyramid's console scripts (``pserve``, ``pviews``, etc) can now be run directly, allowing custom arguments to be sent to the python interpreter @@ -105,6 +126,27 @@ Deprecations the SignedCookieSessionFactory are not. See https://github.com/Pylons/pyramid/pull/1142 +- The ``pyramid.security.has_permission`` API is now deprecated. Instead, use + the newly-added ``has_permission`` method of the request object. + +- The ``pyramid.security.forget`` API is now deprecated. Instead, use + the newly-added ``forget_userid`` method of the request object. + +- The ``pyramid.security.remember`` API is now deprecated. Instead, use + the newly-added ``remember_userid`` method of the request object. + +- The ``pyramid.security.effective_principals`` API is now deprecated. + Instead, use the newly-added ``effective_principals`` attribute of the + request object. + +- The ``pyramid.security.authenticated_userid`` API is now deprecated. + Instead, use the newly-added ``authenticated_userid`` attribute of the + request object. + +- The ``pyramid.security.unauthenticated_userid`` API is now deprecated. + Instead, use the newly-added ``unauthenticated_userid`` attribute of the + request object. + 1.5a2 (2013-09-22) ================== diff --git a/docs/api/request.rst b/docs/api/request.rst index 72abddb68..3d1fe020c 100644 --- a/docs/api/request.rst +++ b/docs/api/request.rst @@ -11,7 +11,10 @@ :exclude-members: add_response_callback, add_finished_callback, route_url, route_path, current_route_url, current_route_path, static_url, static_path, - model_url, resource_url, set_property + model_url, resource_url, set_property, + effective_principals, authenticated_userid, + unauthenticated_userid, has_permission, forget_userid, + remember_userid .. attribute:: context @@ -161,6 +164,42 @@ request, the value of this attribute will be ``None``. See :ref:`matched_route`. + .. attribute:: authenticated_userid + + .. versionadded:: 1.5 + + A property which returns the userid of the currently authenticated user + or ``None`` if there is no :term:`authentication policy` in effect or + there is no currently authenticated user. This differs from + :meth:`~pyramid.request.Request.unauthenticated_userid`, because the + effective authentication policy will have ensured that a record + associated with the userid exists in persistent storage; if it has + not, this value will be ``None``. + + .. attribute:: unauthenticated_userid + + .. versionadded:: 1.5 + + A property which returns a value which represents the *claimed* (not + verified) user id of the credentials present in the request. ``None`` if + there is no :term:`authentication policy` in effect or there is no user + data associated with the current request. This differs from + :meth:`~pyramid.request.Request.authenticated_userid`, because the + effective authentication policy will not ensure that a record associated + with the userid exists in persistent storage. Even if the userid + does not exist in persistent storage, this value will be the value + of the userid *claimed* by the request data. + + .. attribute:: effective_principals + + .. versionadded:: 1.5 + + A property which returns the list of 'effective' :term:`principal` + identifiers for this request. This will include the userid of the + currently authenticated user if a user is currently authenticated. If no + :term:`authentication policy` is in effect, this will return a sequence + containing only the :attr:`pyramid.security.Everyone` principal. + .. method:: invoke_subrequest(request, use_tweens=False) .. versionadded:: 1.4a1 @@ -215,6 +254,12 @@ request provided by e.g. the ``pshell`` environment. For more information, see :ref:`subrequest_chapter`. + .. automethod:: remember_userid + + .. automethod:: forget_userid + + .. automethod:: has_permission + .. automethod:: add_response_callback .. automethod:: add_finished_callback diff --git a/pyramid/config/routes.py b/pyramid/config/routes.py index 5a671c819..4fd207600 100644 --- a/pyramid/config/routes.py +++ b/pyramid/config/routes.py @@ -237,10 +237,10 @@ class RoutesConfiguratorMixin(object): If specified, this value should be a :term:`principal` identifier or a sequence of principal identifiers. If the - :meth:`pyramid.request.Request.effective_principals` method indicates that - every principal named in the argument list is present in the current - request, this predicate will return True; otherwise it will return - False. For example: + :attr:`pyramid.request.Request.effective_principals` property + indicates that every principal named in the argument list is present + in the current request, this predicate will return True; otherwise it + will return False. For example: ``effective_principals=pyramid.security.Authenticated`` or ``effective_principals=('fred', 'group:admins')``. diff --git a/pyramid/config/testing.py b/pyramid/config/testing.py index a006c4767..5df726a31 100644 --- a/pyramid/config/testing.py +++ b/pyramid/config/testing.py @@ -47,14 +47,14 @@ class TestingConfiguratorMixin(object): ``groupids`` argument. The authentication policy will return the userid identifier implied by the ``userid`` argument and the group ids implied by the ``groupids`` argument when the - :meth:`pyramid.request.Request.authenticated_userid` or - :meth:`pyramid.request.Request.effective_principals` APIs are + :attr:`pyramid.request.Request.authenticated_userid` or + :attr:`pyramid.request.Request.effective_principals` APIs are used. This function is most useful when testing code that uses the APIs named :meth:`pyramid.request.Request.has_permission`, - :meth:`pyramid.request.Request.authenticated_userid`, - :meth:`pyramid.request.Request.effective_principals`, and + :attr:`pyramid.request.Request.authenticated_userid`, + :attr:`pyramid.request.Request.effective_principals`, and :func:`pyramid.security.principals_allowed_by_permission`. .. versionadded:: 1.4 diff --git a/pyramid/config/views.py b/pyramid/config/views.py index b0cd785f5..a3f885504 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -1017,10 +1017,10 @@ class ViewsConfiguratorMixin(object): If specified, this value should be a :term:`principal` identifier or a sequence of principal identifiers. If the - :meth:`pyramid.request.Request.effective_principals` method indicates that - every principal named in the argument list is present in the current - request, this predicate will return True; otherwise it will return - False. For example: + :attr:`pyramid.request.Request.effective_principals` property + indicates that every principal named in the argument list is present + in the current request, this predicate will return True; otherwise it + will return False. For example: ``effective_principals=pyramid.security.Authenticated`` or ``effective_principals=('fred', 'group:admins')``. diff --git a/pyramid/request.py b/pyramid/request.py index da640ea7d..188e968ac 100644 --- a/pyramid/request.py +++ b/pyramid/request.py @@ -21,7 +21,10 @@ from pyramid.compat import ( from pyramid.decorator import reify from pyramid.i18n import LocalizerRequestMixin from pyramid.response import Response -from pyramid.security import AuthenticationAPIMixin, AuthorizationAPIMixin +from pyramid.security import ( + AuthenticationAPIMixin, + AuthorizationAPIMixin, + ) from pyramid.url import URLMethodsMixin from pyramid.util import InstancePropertyMixin @@ -137,13 +140,15 @@ class CallbackMethodsMixin(object): callback(self) @implementer(IRequest) -class Request(BaseRequest, - URLMethodsMixin, - CallbackMethodsMixin, - InstancePropertyMixin, - LocalizerRequestMixin, - AuthenticationAPIMixin, - AuthorizationAPIMixin): +class Request( + BaseRequest, + URLMethodsMixin, + CallbackMethodsMixin, + InstancePropertyMixin, + LocalizerRequestMixin, + AuthenticationAPIMixin, + AuthorizationAPIMixin, + ): """ A subclass of the :term:`WebOb` Request class. An instance of this class is created by the :term:`router` and is provided to a diff --git a/pyramid/security.py b/pyramid/security.py index b5e0a2c78..27612206a 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -1,3 +1,4 @@ +from zope.deprecation import deprecated from zope.interface import providedBy from pyramid.interfaces import ( @@ -37,57 +38,152 @@ def _get_registry(request): reg = get_current_registry() # b/c return reg -# b/c def has_permission(permission, context, request): - """ Backwards compatible wrapper. + """ + A function that calls + :meth:`pyramid.request.Request.has_permission` and returns its result. + + .. deprecated:: 1.5 + Use :meth:`pyramid.request.Request.has_permission` instead. - Delegates to the :meth:``pyramid.request.Request.has_permission`` method. + .. versionchanged:: 1.5a3 + If context is None, then attempt to use the context attribute + of self, if not set then the AttributeError is propergated. """ return request.has_permission(permission, context) -# b/c -def authenticated_userid(request): - """ Backwards compatible wrapper. +deprecated( + 'has_permission', + 'As of Pyramid 1.5 the "pyramid.security.has_permission" API is now ' + 'deprecated. It will be removed in Pyramd 1.8. Use the ' + '"has_permission" method of the Pyramid request instead.' + ) + - Delegates to the - :meth:``pyramid.request.Request.authenticated_userid`` method. +def authenticated_userid(request): + """ + A function that returns the value of the property + :attr:`pyramid.request.Request.authenticated_userid`. + + .. deprecated:: 1.5 + Use :attr:`pyramid.request.Request.authenticated_userid` instead. """ return request.authenticated_userid -# b/c -def unauthenticated_userid(request): - """ Backwards compatible wrapper. +deprecated( + 'authenticated_userid', + 'As of Pyramid 1.5 the "pyramid.security.authenticated_userid" API is now ' + 'deprecated. It will be removed in Pyramd 1.8. Use the ' + '"authenticated_userid" attribute of the Pyramid request instead.' + ) - Delegates to the - :meth:``pyramid.request.Request.unauthenticated_userid`` method. +def unauthenticated_userid(request): + """ + A function that returns the value of the property + :attr:`pyramid.request.Request.unauthenticated_userid`. + + .. deprecated:: 1.5 + Use :attr:`pyramid.request.Request.unauthenticated_userid` instead. """ return request.unauthenticated_userid -# b/c -def effective_principals(request): - """ Backwards compatible wrapper. +deprecated( + 'unauthenticated_userid', + 'As of Pyramid 1.5 the "pyramid.security.unauthenticated_userid" API is ' + 'now deprecated. It will be removed in Pyramd 1.8. Use the ' + '"unauthenticated_userid" attribute of the Pyramid request instead.' + ) - Delegates to the - :meth:``pyramid.request.Request.effective_principals`` method. +def effective_principals(request): + """ + A function that returns the value of the property + :attr:`pyramid.request.Request.effective_principals`. + + .. deprecated:: 1.5 + Use :attr:`pyramid.request.Request.effective_principals` instead. """ return request.effective_principals -# b/c -def remember(request, principal, **kw): - """ Backwards compatible wrapper. +deprecated( + 'effective_principals', + 'As of Pyramid 1.5 the "pyramid.security.effective_principals" API is ' + 'now deprecated. It will be removed in Pyramd 1.8. Use the ' + '"effective_principals" attribute of the Pyramid request instead.' + ) - Delegates to the :meth:``pyramid.request.Request.remember_userid`` method. - """ +def remember(request, principal, **kw): + """ + Returns a sequence of header tuples (e.g. ``[('Set-Cookie', + 'foo=abc')]``) on this request's response. + These headers are suitable for 'remembering' a set of credentials + implied by the data passed as ``principal`` and ``*kw`` using the + current :term:`authentication policy`. Common usage might look + like so within the body of a view function (``response`` is + assumed to be a :term:`WebOb` -style :term:`response` object + computed previously by the view code):: + + .. code-block:: python + + from pyramid.security import remember + headers = remember(request, 'chrism', password='123', max_age='86400') + response.headerlist.extend(headers) + return response + + If no :term:`authentication policy` is in use, this function will + do nothing. If used, the composition and + meaning of ``**kw`` must be agreed upon by the calling code and + the effective authentication policy. + + .. deprecated:: 1.5 + Use :meth:`pyramid.request.Request.remember_userid` instead. + but be sure to read its docs first; the remember_userid method is not an + exact analog of the remember function, because it sets headers instead + of returning them. + """ return request._remember_userid(principal, **kw) -# b/c -def forget(request): - """ Backwards compatible wrapper. +deprecated( + 'remember', + 'As of Pyramid 1.5 the "pyramid.security.remember" API is ' + 'now deprecated. It will be removed in Pyramd 1.8. Use the ' + '"remember_userid" method of the Pyramid request instead, but be sure to ' + 'read the docs first; the remember_userid method is not an exact analog of ' + 'the remember function, because it sets headers instead of returning them.' + ) - Delegates to the :meth:``pyramid.request.Request.forget_userid`` method. +def forget(request): + """ + Return a sequence of header tuples (e.g. ``[('Set-Cookie', + 'foo=abc')]``) suitable for 'forgetting' the set of credentials + possessed by the currently authenticated user. A common usage + might look like so within the body of a view function + (``response`` is assumed to be an :term:`WebOb` -style + :term:`response` object computed previously by the view code):: + + from pyramid.security import forget + headers = forget(request) + response.headerlist.extend(headers) + return response + + If no :term:`authentication policy` is in use, this function will + always return an empty sequence. + + .. deprecated:: 1.5 + Use :meth:`pyramid.request.Request.forget_userid` instead. + but be sure to read its docs first; the forget_userid method is not an + exact analog of the forget function, because it sets headers instead + of returning them. """ return request._forget_userid() +deprecated( + 'forget', + 'As of Pyramid 1.5 the "pyramid.security.forget" API is ' + 'now deprecated. It will be removed in Pyramd 1.8. Use the ' + '"forget_user" method of the Pyramid request instead, but be sure to ' + 'read the docs first; the forget_userid method is not an exact analog of ' + 'the forget function, because it sets headers instead of returning them.' + ) def principals_allowed_by_permission(context, permission): """ Provided a ``context`` (a resource object), and a ``permission`` @@ -234,7 +330,10 @@ class AuthenticationAPIMixin(object): def authenticated_userid(self): """ Return the userid of the currently authenticated user or ``None`` if there is no :term:`authentication policy` in effect or - there is no currently authenticated user.""" + there is no currently authenticated user. + + .. versionadded:: 1.5 + """ policy = self._get_authentication_policy() if policy is None: return None @@ -248,7 +347,10 @@ class AuthenticationAPIMixin(object): associated with the current request. This differs from :func:`~pyramid.security.authenticated_userid`, because the effective authentication policy will not ensure that a record associated with the - userid exists in persistent storage.""" + userid exists in persistent storage. + + .. versionadded:: 1.5 + """ policy = self._get_authentication_policy() if policy is None: return None @@ -260,7 +362,10 @@ class AuthenticationAPIMixin(object): for the ``request``. This will include the userid of the currently authenticated user if a user is currently authenticated. If no :term:`authentication policy` is in effect, - this will return an empty sequence.""" + this will return an empty sequence. + + .. versionadded:: 1.5 + """ policy = self._get_authentication_policy() if policy is None: return [Everyone] @@ -290,7 +395,11 @@ class AuthenticationAPIMixin(object): If no :term:`authentication policy` is in use, this function will do nothing. If used, the composition and meaning of ``**kw`` must be agreed upon by the calling code and - the effective authentication policy.""" + the effective authentication policy. + + .. versionadded:: 1.5 + + """ headers = self._remember_userid(principal, **kw) callback = lambda req, response: response.headerlist.extend(headers) self.add_response_callback(callback) @@ -315,9 +424,12 @@ class AuthenticationAPIMixin(object): request.forget_userid() If no :term:`authentication policy` is in use, this function will - be a noop.""" + be a noop. + + .. versionadded:: 1.5 + """ headers = self._forget_userid() - callback = lambda req, response: response.headerlist.extend(headers) + callback = lambda req, response: response.headerlist.extend(headers) self.add_response_callback(callback) class AuthorizationAPIMixin(object): @@ -333,15 +445,14 @@ class AuthorizationAPIMixin(object): unconditionally if no authentication policy has been registered for this request. - .. versionchanged:: 1.5a3 - If context is None, then attempt to use the context attribute - of self, if not set then the AttributeError is propergated. - :param permission: Does this request have the given permission? :type permission: unicode, str :param context: Typically a resource of a regsitered type. :type context: object :returns: `pyramid.security.PermitsResult` + + .. versionadded:: 1.5 + """ if context is None: context = self.context diff --git a/pyramid/testing.py b/pyramid/testing.py index 2416c7b34..b3460d8aa 100644 --- a/pyramid/testing.py +++ b/pyramid/testing.py @@ -283,12 +283,14 @@ class DummySession(dict): return token @implementer(IRequest) -class DummyRequest(URLMethodsMixin, - CallbackMethodsMixin, - InstancePropertyMixin, - LocalizerRequestMixin, - AuthenticationAPIMixin, - AuthorizationAPIMixin): +class DummyRequest( + URLMethodsMixin, + CallbackMethodsMixin, + InstancePropertyMixin, + LocalizerRequestMixin, + AuthenticationAPIMixin, + AuthorizationAPIMixin, + ): """ A DummyRequest object (incompletely) imitates a :term:`request` object. The ``params``, ``environ``, ``headers``, ``path``, and diff --git a/pyramid/tests/test_security.py b/pyramid/tests/test_security.py index 4b40feaf3..b685ddc97 100644 --- a/pyramid/tests/test_security.py +++ b/pyramid/tests/test_security.py @@ -242,9 +242,17 @@ class AuthenticationAPIMixinTest(object): class TestAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): def test_backward_compat_delegates_to_mixin(self): - request = self._makeFakeOne() - from pyramid.security import authenticated_userid - self.assertEqual(authenticated_userid(request), 'authenticated_userid') + from zope.deprecation import __show__ + try: + __show__.off() + request = self._makeFakeOne() + from pyramid.security import authenticated_userid + self.assertEqual( + authenticated_userid(request), + 'authenticated_userid' + ) + finally: + __show__.on() def test_no_authentication_policy(self): request = self._makeOne() @@ -265,10 +273,17 @@ class TestAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): class TestUnAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): def test_backward_compat_delegates_to_mixin(self): - request = self._makeFakeOne() - from pyramid.security import unauthenticated_userid - self.assertEqual(unauthenticated_userid(request), - 'unauthenticated_userid') + from zope.deprecation import __show__ + try: + __show__.off() + request = self._makeFakeOne() + from pyramid.security import unauthenticated_userid + self.assertEqual( + unauthenticated_userid(request), + 'unauthenticated_userid', + ) + finally: + __show__.on() def test_no_authentication_policy(self): request = self._makeOne() @@ -290,8 +305,16 @@ class TestUnAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): class TestEffectivePrincipals(AuthenticationAPIMixinTest, unittest.TestCase): def test_backward_compat_delegates_to_mixin(self): request = self._makeFakeOne() - from pyramid.security import effective_principals - self.assertEqual(effective_principals(request), 'effective_principals') + from zope.deprecation import __show__ + try: + __show__.off() + from pyramid.security import effective_principals + self.assertEqual( + effective_principals(request), + 'effective_principals' + ) + finally: + __show__.on() def test_no_authentication_policy(self): from pyramid.security import Everyone @@ -322,10 +345,17 @@ class TestRememberUserId(ResponseCallbackTestMixin, unittest.TestCase): principal = 'the4th' def test_backward_compat_delegates_to_mixin(self): - request = self._makeFakeOne() - from pyramid.security import remember - self.assertEqual(remember(request, 'matt'), - [('X-Pyramid-Test', 'remember_userid')]) + from zope.deprecation import __show__ + try: + __show__.off() + request = self._makeFakeOne() + from pyramid.security import remember + self.assertEqual( + remember(request, 'matt'), + [('X-Pyramid-Test', 'remember_userid')] + ) + finally: + __show__.on() def test_with_no_authentication_policy(self): request = self._makeOne() @@ -357,10 +387,17 @@ class TestForgetUserId(ResponseCallbackTestMixin, unittest.TestCase): return request def test_backward_compat_delegates_to_mixin(self): - request = self._makeFakeOne() - from pyramid.security import forget - self.assertEqual(forget(request), - [('X-Pyramid-Test', 'forget_userid')]) + from zope.deprecation import __show__ + try: + __show__.off() + request = self._makeFakeOne() + from pyramid.security import forget + self.assertEqual( + forget(request), + [('X-Pyramid-Test', 'forget_userid')], + ) + finally: + __show__.on() def test_with_no_authentication_policy(self): request = self._makeOne() @@ -401,16 +438,21 @@ class TestHasPermission(unittest.TestCase): return mixin def test_delegates_to_mixin(self): - mixin = self._makeOne() - from pyramid.security import has_permission - self.called_has_permission = False - - def mocked_has_permission(*args, **kw): - self.called_has_permission = True - - mixin.has_permission = mocked_has_permission - has_permission('view', object(), mixin) - self.assertTrue(self.called_has_permission) + from zope.deprecation import __show__ + try: + __show__.off() + mixin = self._makeOne() + from pyramid.security import has_permission + self.called_has_permission = False + + def mocked_has_permission(*args, **kw): + self.called_has_permission = True + + mixin.has_permission = mocked_has_permission + has_permission('view', object(), mixin) + self.assertTrue(self.called_has_permission) + finally: + __show__.on() def test_no_authentication_policy(self): request = self._makeOne() -- cgit v1.2.3 From 072a2cd56877ce46f9db2fb6f576ef62ecefff15 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 28 Oct 2013 17:06:30 -0400 Subject: add on_exception flag to remember/forget, fix a bug in _remember_userid and _forget_userid (these should always return a sequence even if there is no authentication policy), defactorize tests --- pyramid/security.py | 38 +++++-- pyramid/tests/test_security.py | 249 ++++++++++++++++++++++++++--------------- 2 files changed, 189 insertions(+), 98 deletions(-) diff --git a/pyramid/security.py b/pyramid/security.py index 27612206a..1b52c9cb5 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -130,7 +130,7 @@ def remember(request, principal, **kw): return response If no :term:`authentication policy` is in use, this function will - do nothing. If used, the composition and + always return an empty sequence. If used, the composition and meaning of ``**kw`` must be agreed upon by the calling code and the effective authentication policy. @@ -375,10 +375,10 @@ class AuthenticationAPIMixin(object): def _remember_userid(self, principal, **kw): policy = self._get_authentication_policy() if policy is None: - return + return [] return policy.remember(self, principal, **kw) - def remember_userid(self, principal, **kw): + def remember_userid(self, principal, on_exception=False, **kw): """ Sets a sequence of header tuples (e.g. ``[('Set-Cookie', 'foo=abc')]``) on this request's response. These headers are suitable for 'remembering' a set of credentials @@ -397,21 +397,34 @@ class AuthenticationAPIMixin(object): meaning of ``**kw`` must be agreed upon by the calling code and the effective authentication policy. + One special keyword value is understood by this method: + ``on_exception``. Usually if an exception occurs within the same + request after this method is called, the headers provided by the + authentication policy will not be set on the response. If + ``on_exception`` is passed, and as ``True``, then the headers will be + set on the response even if an exception is later raised. By default + this value is ``False``. + .. versionadded:: 1.5 """ headers = self._remember_userid(principal, **kw) - callback = lambda req, response: response.headerlist.extend(headers) + def callback(req, response): + # do not set the headers on an exception unless explicitly + # instructed + exc = getattr(req, 'exception', None) + if exc is None or on_exception: + response.headerlist.extend(headers) self.add_response_callback(callback) # b/c def _forget_userid(self): policy = self._get_authentication_policy() if policy is None: - return + return [] return policy.forget(self) - def forget_userid(self): + def forget_userid(self, on_exception=False): """ Sets a sequence of header tuples (e.g. ``[('Set-Cookie', 'foo=abc')]``) suitable for 'forgetting' the set of credentials possessed by the currently authenticated user on the response. @@ -426,10 +439,21 @@ class AuthenticationAPIMixin(object): If no :term:`authentication policy` is in use, this function will be a noop. + One special keyword value is understood by this method: + ``on_exception``. Usually if an exception occurs within the same + request after this method is called, the headers provided by the + authentication policy will not be set on the response. If + ``on_exception`` is passed, and as ``True``, then the headers will be + set on the response even if an exception is later raised. By default + this value is ``False``. + .. versionadded:: 1.5 """ headers = self._forget_userid() - callback = lambda req, response: response.headerlist.extend(headers) + def callback(req, response): + exc = getattr(req, 'exception', None) + if exc is None or on_exception: + response.headerlist.extend(headers) self.add_response_callback(callback) class AuthorizationAPIMixin(object): diff --git a/pyramid/tests/test_security.py b/pyramid/tests/test_security.py index b685ddc97..96f171324 100644 --- a/pyramid/tests/test_security.py +++ b/pyramid/tests/test_security.py @@ -1,15 +1,13 @@ import unittest -from pyramid.testing import cleanUp, DummyRequest - -_TEST_HEADER = 'X-Pyramid-Test' +from pyramid import testing class TestAllPermissionsList(unittest.TestCase): def setUp(self): - cleanUp() + testing.setUp() def tearDown(self): - cleanUp() + testing.tearDown() def _getTargetClass(self): from pyramid.security import AllPermissionsList @@ -106,10 +104,10 @@ class TestACLDenied(unittest.TestCase): class TestPrincipalsAllowedByPermission(unittest.TestCase): def setUp(self): - cleanUp() + testing.setUp() def tearDown(self): - cleanUp() + testing.tearDown() def _callFUT(self, *arg): from pyramid.security import principals_allowed_by_permission @@ -131,10 +129,10 @@ class TestPrincipalsAllowedByPermission(unittest.TestCase): class TestViewExecutionPermitted(unittest.TestCase): def setUp(self): - cleanUp() + testing.setUp() def tearDown(self): - cleanUp() + testing.tearDown() def _callFUT(self, *arg, **kw): from pyramid.security import view_execution_permitted @@ -166,7 +164,7 @@ class TestViewExecutionPermitted(unittest.TestCase): reg = get_current_registry() reg.registerUtility(settings, ISettings) context = DummyContext() - request = DummyRequest({}) + request = testing.DummyRequest({}) class DummyView(object): pass view = DummyView() @@ -185,7 +183,7 @@ class TestViewExecutionPermitted(unittest.TestCase): reg = get_current_registry() reg.registerUtility(settings, ISettings) context = DummyContext() - request = DummyRequest({}) + request = testing.DummyRequest({}) self.assertRaises(TypeError, self._callFUT, context, request, '') def test_with_permission(self): @@ -197,55 +195,23 @@ class TestViewExecutionPermitted(unittest.TestCase): context = DummyContext() directlyProvides(context, IContext) self._registerSecuredView('', True) - request = DummyRequest({}) + request = testing.DummyRequest({}) directlyProvides(request, IRequest) result = self._callFUT(context, request, '') self.assertTrue(result) -class AuthenticationAPIMixinTest(object): +class TestAuthenticatedUserId(unittest.TestCase): def setUp(self): - cleanUp() + testing.setUp() def tearDown(self): - cleanUp() - - def _makeOne(self): - from pyramid.registry import Registry - from pyramid.security import AuthenticationAPIMixin - request = DummyRequest(environ={}) - self.assertTrue(isinstance(request, AuthenticationAPIMixin)) - request.registry = Registry() - request.context = object() - return request - - def _makeFakeOne(self): - class FakeRequest(DummyRequest): - @property - def authenticated_userid(req): - return 'authenticated_userid' - - @property - def unauthenticated_userid(req): - return 'unauthenticated_userid' - - @property - def effective_principals(req): - return 'effective_principals' - - def _forget_userid(req): - return [('X-Pyramid-Test', 'forget_userid')] - - def _remember_userid(req, principal, **kw): - return [('X-Pyramid-Test', 'remember_userid')] - - return FakeRequest({}) - -class TestAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): + testing.tearDown() + def test_backward_compat_delegates_to_mixin(self): from zope.deprecation import __show__ try: __show__.off() - request = self._makeFakeOne() + request = _makeFakeRequest() from pyramid.security import authenticated_userid self.assertEqual( authenticated_userid(request), @@ -255,28 +221,34 @@ class TestAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): __show__.on() def test_no_authentication_policy(self): - request = self._makeOne() + request = _makeRequest() self.assertEqual(request.authenticated_userid, None) def test_with_authentication_policy(self): - request = self._makeOne() + request = _makeRequest() _registerAuthenticationPolicy(request.registry, 'yo') self.assertEqual(request.authenticated_userid, 'yo') def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() - request = self._makeOne() + request = _makeRequest() del request.registry _registerAuthenticationPolicy(registry, 'yo') self.assertEqual(request.authenticated_userid, 'yo') -class TestUnAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): +class TestUnAuthenticatedUserId(unittest.TestCase): + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + def test_backward_compat_delegates_to_mixin(self): from zope.deprecation import __show__ try: __show__.off() - request = self._makeFakeOne() + request = _makeFakeRequest() from pyramid.security import unauthenticated_userid self.assertEqual( unauthenticated_userid(request), @@ -286,25 +258,31 @@ class TestUnAuthenticatedUserId(AuthenticationAPIMixinTest, unittest.TestCase): __show__.on() def test_no_authentication_policy(self): - request = self._makeOne() + request = _makeRequest() self.assertEqual(request.unauthenticated_userid, None) def test_with_authentication_policy(self): - request = self._makeOne() + request = _makeRequest() _registerAuthenticationPolicy(request.registry, 'yo') self.assertEqual(request.unauthenticated_userid, 'yo') def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() - request = self._makeOne() + request = _makeRequest() del request.registry _registerAuthenticationPolicy(registry, 'yo') self.assertEqual(request.unauthenticated_userid, 'yo') -class TestEffectivePrincipals(AuthenticationAPIMixinTest, unittest.TestCase): +class TestEffectivePrincipals(unittest.TestCase): + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + def test_backward_compat_delegates_to_mixin(self): - request = self._makeFakeOne() + request = _makeFakeRequest() from zope.deprecation import __show__ try: __show__.off() @@ -318,37 +296,41 @@ class TestEffectivePrincipals(AuthenticationAPIMixinTest, unittest.TestCase): def test_no_authentication_policy(self): from pyramid.security import Everyone - request = self._makeOne() + request = _makeRequest() self.assertEqual(request.effective_principals, [Everyone]) def test_with_authentication_policy(self): - request = self._makeOne() + request = _makeRequest() _registerAuthenticationPolicy(request.registry, 'yo') self.assertEqual(request.effective_principals, 'yo') def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() - request = self._makeOne() + request = _makeRequest() del request.registry _registerAuthenticationPolicy(registry, 'yo') self.assertEqual(request.effective_principals, 'yo') -class ResponseCallbackTestMixin(AuthenticationAPIMixinTest): +class TestRememberUserId(unittest.TestCase): + principal = 'the4th' + + def setUp(self): + testing.setUp() - def assert_response_headers_set(self, request): + def tearDown(self): + testing.tearDown() + + def assert_response_headers(self, request, expected_headers): request._process_response_callbacks(request.response) headers = request.response.headerlist - self.assertTrue((_TEST_HEADER, self.principal) in headers, msg=headers) - -class TestRememberUserId(ResponseCallbackTestMixin, unittest.TestCase): - principal = 'the4th' + self.assertEqual(list(expected_headers), list(headers)) def test_backward_compat_delegates_to_mixin(self): from zope.deprecation import __show__ try: __show__.off() - request = self._makeFakeOne() + request = _makeFakeRequest() from pyramid.security import remember self.assertEqual( remember(request, 'matt'), @@ -358,31 +340,63 @@ class TestRememberUserId(ResponseCallbackTestMixin, unittest.TestCase): __show__.on() def test_with_no_authentication_policy(self): - request = self._makeOne() - headers_before = request.response.headers + request = _makeRequest() + headers_before = request.response.headerlist request.remember_userid(self.principal) - self.assertEqual(headers_before, request.response.headers) + self.assert_response_headers(request, headers_before) def test_with_authentication_policy(self): - request = self._makeOne() + request = _makeRequest() + headers_before = request.response.headerlist + expected_headers = headers_before[:] + [(_TEST_HEADER, self.principal)] _registerAuthenticationPolicy(request.registry, self.principal) request.remember_userid(self.principal) - self.assert_response_headers_set(request) + self.assert_response_headers(request, expected_headers) def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() - request = self._makeOne() + request = _makeRequest() del request.registry _registerAuthenticationPolicy(registry, self.principal) + headers_before = request.response.headerlist + request.remember_userid(self.principal) + expected_headers = headers_before[:] + [(_TEST_HEADER, self.principal)] + self.assert_response_headers(request, expected_headers) + + def test_request_has_exception_attr_no_on_exception_flag(self): + request = _makeRequest() + headers_before = request.response.headerlist + _registerAuthenticationPolicy(request.registry, self.principal) + request.exception = True request.remember_userid(self.principal) - self.assert_response_headers_set(request) + self.assert_response_headers(request, headers_before) -class TestForgetUserId(ResponseCallbackTestMixin, unittest.TestCase): + def test_request_has_exception_attr_with_on_exception_flag(self): + request = _makeRequest() + headers_before = request.response.headerlist + _registerAuthenticationPolicy(request.registry, self.principal) + request.exception = True + request.remember_userid(self.principal, on_exception=True) + expected_headers = headers_before[:] + [(_TEST_HEADER, self.principal)] + self.assert_response_headers(request, expected_headers) + +class TestForgetUserId(unittest.TestCase): principal = 'me-not' + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + + def assert_response_headers(self, request, expected_headers): + request._process_response_callbacks(request.response) + headers = request.response.headerlist + self.assertEqual(list(expected_headers), list(headers)) + def _makeOne(self): - request = super(TestForgetUserId, self)._makeOne() + request = _makeRequest() request.response.headers.add(_TEST_HEADER, self.principal) return request @@ -390,7 +404,7 @@ class TestForgetUserId(ResponseCallbackTestMixin, unittest.TestCase): from zope.deprecation import __show__ try: __show__.off() - request = self._makeFakeOne() + request = _makeFakeRequest() from pyramid.security import forget self.assertEqual( forget(request), @@ -401,33 +415,52 @@ class TestForgetUserId(ResponseCallbackTestMixin, unittest.TestCase): def test_with_no_authentication_policy(self): request = self._makeOne() - headers_before = request.response.headers + headers_before = request.response.headerlist request.forget_userid() - self.assertEqual(headers_before, request.response.headers) + self.assert_response_headers(request, headers_before) def test_with_authentication_policy(self): request = self._makeOne() - policy = _registerAuthenticationPolicy(request.registry, self.principal) - policy._header_remembered = (_TEST_HEADER, self.principal) + headers_before = request.response.headerlist + expected_headers = headers_before[:] + [(_TEST_HEADER, 'forget_userid')] + _registerAuthenticationPolicy(request.registry, self.principal) request.forget_userid() - self.assert_response_headers_set(request) - + self.assert_response_headers(request, expected_headers) + def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry registry = get_current_registry() request = self._makeOne() del request.registry - policy = _registerAuthenticationPolicy(registry, self.principal) - policy._header_remembered = (_TEST_HEADER, self.principal) + _registerAuthenticationPolicy(registry, self.principal) + headers_before = request.response.headerlist request.forget_userid() - self.assert_response_headers_set(request) + expected_headers = headers_before[:] + [(_TEST_HEADER, 'forget_userid')] + self.assert_response_headers(request, expected_headers) + + def test_request_has_exception_attr_no_on_exception_flag(self): + request = self._makeOne() + headers_before = request.response.headerlist + _registerAuthenticationPolicy(request.registry, self.principal) + request.exception = True + request.forget_userid() + self.assert_response_headers(request, headers_before) + + def test_request_has_exception_attr_with_on_exception_flag(self): + request = self._makeOne() + headers_before = request.response.headerlist + _registerAuthenticationPolicy(request.registry, self.principal) + request.exception = True + request.forget_userid(on_exception=True) + expected_headers = headers_before[:] + [(_TEST_HEADER, 'forget_userid')] + self.assert_response_headers(request, expected_headers) class TestHasPermission(unittest.TestCase): def setUp(self): - cleanUp() + testing.setUp() def tearDown(self): - cleanUp() + testing.tearDown() def _makeOne(self): from pyramid.security import AuthorizationAPIMixin @@ -490,6 +523,8 @@ class TestHasPermission(unittest.TestCase): del request.context self.assertRaises(AttributeError, request.has_permission, 'view') +_TEST_HEADER = 'X-Pyramid-Test' + class DummyContext: def __init__(self, *arg, **kw): self.__dict__.update(kw) @@ -513,7 +548,9 @@ class DummyAuthenticationPolicy: return headers def forget(self, request): - return [self._header_remembered] + headers = [(_TEST_HEADER, 'forget_userid')] + self._header_forgotten = headers[0] + return headers class DummyAuthorizationPolicy: def __init__(self, result): @@ -536,3 +573,33 @@ def _registerAuthorizationPolicy(reg, result): policy = DummyAuthorizationPolicy(result) reg.registerUtility(policy, IAuthorizationPolicy) return policy + +def _makeRequest(): + from pyramid.registry import Registry + request = testing.DummyRequest(environ={}) + request.registry = Registry() + request.context = object() + return request + +def _makeFakeRequest(): + class FakeRequest(testing.DummyRequest): + @property + def authenticated_userid(req): + return 'authenticated_userid' + + @property + def unauthenticated_userid(req): + return 'unauthenticated_userid' + + @property + def effective_principals(req): + return 'effective_principals' + + def _forget_userid(req): + return [('X-Pyramid-Test', 'forget_userid')] + + def _remember_userid(req, principal, **kw): + return [('X-Pyramid-Test', 'remember_userid')] + + return FakeRequest({}) + -- cgit v1.2.3 From 15d4efeb012998e7675657ea3d781562957a4f71 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 28 Oct 2013 17:11:53 -0400 Subject: defer looking up headers until the response callback is called (FBO things like sessionauthenticationpolicy which does its own header-setting when its remember/forget methods are called) --- pyramid/security.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyramid/security.py b/pyramid/security.py index 1b52c9cb5..afca8cd9a 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -408,12 +408,12 @@ class AuthenticationAPIMixin(object): .. versionadded:: 1.5 """ - headers = self._remember_userid(principal, **kw) def callback(req, response): # do not set the headers on an exception unless explicitly # instructed exc = getattr(req, 'exception', None) if exc is None or on_exception: + headers = self._remember_userid(principal, **kw) response.headerlist.extend(headers) self.add_response_callback(callback) @@ -449,10 +449,10 @@ class AuthenticationAPIMixin(object): .. versionadded:: 1.5 """ - headers = self._forget_userid() def callback(req, response): exc = getattr(req, 'exception', None) if exc is None or on_exception: + headers = self._forget_userid() response.headerlist.extend(headers) self.add_response_callback(callback) -- cgit v1.2.3 From 2478de31b2e6d8d7667b9dd0c81f571130f3daf6 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 28 Oct 2013 17:12:54 -0400 Subject: avoid a deprecation warning during test runs --- pyramid/config/predicates.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyramid/config/predicates.py b/pyramid/config/predicates.py index c8f66e83d..967f2eeee 100644 --- a/pyramid/config/predicates.py +++ b/pyramid/config/predicates.py @@ -13,7 +13,6 @@ from pyramid.traversal import ( from pyramid.urldispatch import _compile_route from pyramid.util import object_description from pyramid.session import check_csrf_token -from pyramid.security import effective_principals from .util import as_sorted_tuple @@ -288,7 +287,7 @@ class EffectivePrincipalsPredicate(object): phash = text def __call__(self, context, request): - req_principals = effective_principals(request) + req_principals = request.effective_principals if is_nonstr_iter(req_principals): rpset = set(req_principals) if self.val.issubset(rpset): -- cgit v1.2.3 From 5431fdc645019c0b5eb0a60f41cd77aa3457ae07 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 28 Oct 2013 17:19:35 -0400 Subject: add NB notes about recursive add_response_callback policies, use req instead of self for normalization with exception getting --- pyramid/security.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/pyramid/security.py b/pyramid/security.py index afca8cd9a..0d10b3998 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -413,7 +413,11 @@ class AuthenticationAPIMixin(object): # instructed exc = getattr(req, 'exception', None) if exc is None or on_exception: - headers = self._remember_userid(principal, **kw) + # NB: this call to _remember_userid should be exactly here + # because some policies actually add another response callback + # when their remember method is called, and we dont want them + # to do that if there's an exception in the default case. + headers = req._remember_userid(principal, **kw) response.headerlist.extend(headers) self.add_response_callback(callback) @@ -452,7 +456,11 @@ class AuthenticationAPIMixin(object): def callback(req, response): exc = getattr(req, 'exception', None) if exc is None or on_exception: - headers = self._forget_userid() + # NB: this call to _forget_userid should be exactly here + # because some policies actually add another response callback + # when their forget method is called, and we dont want them + # to do that if there's an exception in the default case. + headers = req._forget_userid() response.headerlist.extend(headers) self.add_response_callback(callback) -- cgit v1.2.3 From 0921db8b34c6b1967f249d42dee6b652a3b987ef Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 28 Oct 2013 17:24:30 -0400 Subject: wording and specify return value --- pyramid/security.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/pyramid/security.py b/pyramid/security.py index 0d10b3998..b0fd38678 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -380,18 +380,19 @@ class AuthenticationAPIMixin(object): def remember_userid(self, principal, on_exception=False, **kw): """ Sets a sequence of header tuples (e.g. ``[('Set-Cookie', - 'foo=abc')]``) on this request's response. - These headers are suitable for 'remembering' a set of credentials - implied by the data passed as ``principal`` and ``*kw`` using the - current :term:`authentication policy`. Common usage might look - like so within the body of a view function (``response`` is - assumed to be a :term:`WebOb` -style :term:`response` object - computed previously by the view code):: + 'foo=abc')]``) on the response eventually returned using a response + callback. These headers are used for 'remembering' a set of + credentials implied by the data passed as ``principal`` and ``*kw`` + using the current :term:`authentication policy`. Common usage might + look like so within the body of a view function:: .. code-block:: python request.remember_userid('chrism', password='123', max_age='86400') + This method always returns ``None``; it is called only for its side + effects. + If no :term:`authentication policy` is in use, this function will do nothing. If used, the composition and meaning of ``**kw`` must be agreed upon by the calling code and @@ -431,18 +432,15 @@ class AuthenticationAPIMixin(object): def forget_userid(self, on_exception=False): """ Sets a sequence of header tuples (e.g. ``[('Set-Cookie', 'foo=abc')]``) suitable for 'forgetting' the set of credentials - possessed by the currently authenticated user on the response. - A common usage might look like so within the body of a view function - (``response`` is assumed to be an :term:`WebOb` -style - :term:`response` object computed previously by the view code):: - - .. code-block:: python - - request.forget_userid() + possessed by the currently authenticated user on the response based on + the current :term:`authentication policy`, using a response callback. If no :term:`authentication policy` is in use, this function will be a noop. + This method always returns ``None``; it is called only for its side + effects. + One special keyword value is understood by this method: ``on_exception``. Usually if an exception occurs within the same request after this method is called, the headers provided by the -- cgit v1.2.3 From 696e0e3bd257fdace57adbb4c3d331af377d9e5b Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 19:47:52 -0400 Subject: fix zodb tutorial wrt request-based authentication and authorization apis --- docs/tutorials/wiki/authorization.rst | 34 +++++++--------------- .../wiki/src/authorization/tutorial/views.py | 22 +++++--------- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/docs/tutorials/wiki/authorization.rst b/docs/tutorials/wiki/authorization.rst index 2bd8c1f1c..bba303d7f 100644 --- a/docs/tutorials/wiki/authorization.rst +++ b/docs/tutorials/wiki/authorization.rst @@ -56,10 +56,10 @@ returns one of these values: return ``None``. For example, ``groupfinder('editor', request )`` returns ``['group:editor']``, -``groupfinder('viewer', request)`` returns ``[]``, and ``groupfinder('admin', request)`` -returns ``None``. We will use ``groupfinder()`` as an :term:`authentication policy` -"callback" that will provide the :term:`principal` or principals -for a user. +``groupfinder('viewer', request)`` returns ``[]``, and ``groupfinder('admin', +request)`` returns ``None``. We will use ``groupfinder()`` as an +:term:`authentication policy` "callback" that will provide the +:term:`principal` or principals for a user. In a production system, user and group data will most often come from a database, but here we use "dummy" @@ -197,15 +197,15 @@ Add the following import statements to the head of ``tutorial/tutorial/views.py``: .. literalinclude:: src/authorization/tutorial/views.py - :lines: 6-13,15-17 + :lines: 6-11 :linenos: - :emphasize-lines: 3,6-9,11 + :emphasize-lines: 3,6 :language: python (Only the highlighted lines, with other necessary modifications, need to be added.) -:meth:`~pyramid.view.forbidden_view_config` will be used +:func:`~pyramid.view.forbidden_view_config` will be used to customize the default 403 Forbidden page. :meth:`~pyramid.request.Request.remember_userid` and :meth:`~pyramid.request.Request.forget_userid` help to create and @@ -214,7 +214,7 @@ expire an auth ticket cookie. Now add the ``login`` and ``logout`` views: .. literalinclude:: src/authorization/tutorial/views.py - :lines: 82-120 + :lines: 76-102 :linenos: :language: python @@ -251,18 +251,6 @@ in ``views.py``. Return a logged_in flag to the renderer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Add the following line to the import at the head of -``tutorial/tutorial/views.py``: - -.. literalinclude:: src/authorization/tutorial/views.py - :lines: 11-15 - :linenos: - :emphasize-lines: 4 - :language: python - -(Only the highlighted line and a trailing comma on the preceding -line need to be added.) - Add a ``logged_in`` parameter to the return value of ``view_page()``, ``edit_page()`` and ``add_page()``, like this: @@ -274,12 +262,12 @@ like this: return dict(page = page, content = content, edit_url = edit_url, - logged_in = authenticated_userid(request)) + logged_in = request.authenticated_userid) (Only the highlighted line and a trailing comma on the preceding line need to be added.) -:meth:`~pyramid.security.authenticated_userid()` will return ``None`` +:attr:`~pyramid.request.Request.authenticated_userid` will return ``None`` if the user is not authenticated, or a user id if the user is authenticated. @@ -329,7 +317,7 @@ when we're done: .. literalinclude:: src/authorization/tutorial/views.py :linenos: - :emphasize-lines: 8,11-15,17,24,29,48,52,68,72,80,82-120 + :emphasize-lines: 8,11,18,23,42,46,62,66,74,80,76-107 :language: python (Only the highlighted lines need to be added.) diff --git a/docs/tutorials/wiki/src/authorization/tutorial/views.py b/docs/tutorials/wiki/src/authorization/tutorial/views.py index 77956b1e3..57529ac8d 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/views.py +++ b/docs/tutorials/wiki/src/authorization/tutorial/views.py @@ -8,12 +8,6 @@ from pyramid.view import ( forbidden_view_config, ) -from pyramid.security import ( - remember, - forget, - authenticated_userid, - ) - from .security import USERS from .models import Page @@ -45,7 +39,7 @@ def view_page(context, request): edit_url = request.resource_url(context, 'edit_page') return dict(page = context, content = content, edit_url = edit_url, - logged_in = authenticated_userid(request)) + logged_in = request.authenticated_userid) @view_config(name='add_page', context='.models.Wiki', renderer='templates/edit.pt', @@ -65,7 +59,7 @@ def add_page(context, request): page.__parent__ = context return dict(page=page, save_url=save_url, - logged_in=authenticated_userid(request)) + logged_in=request.authenticated_userid) @view_config(name='edit_page', context='.models.Page', renderer='templates/edit.pt', @@ -77,7 +71,7 @@ def edit_page(context, request): return dict(page=context, save_url=request.resource_url(context, 'edit_page'), - logged_in=authenticated_userid(request)) + logged_in=request.authenticated_userid) @view_config(context='.models.Wiki', name='login', renderer='templates/login.pt') @@ -95,9 +89,8 @@ def login(request): login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - headers = remember(request, login) - return HTTPFound(location = came_from, - headers = headers) + request.remember_userid(login) + return HTTPFound(location=came_from) message = 'Failed login' return dict( @@ -110,6 +103,5 @@ def login(request): @view_config(context='.models.Wiki', name='logout') def logout(request): - headers = forget(request) - return HTTPFound(location = request.resource_url(request.context), - headers = headers) + request.forget_userid() + return HTTPFound(location=request.resource_url(request.context)) -- cgit v1.2.3 From f436d7f5cd19e94378737096d9d21635b157fc46 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:03:49 -0400 Subject: copy forward views.py changes to tests step --- docs/tutorials/wiki/src/tests/tutorial/views.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/docs/tutorials/wiki/src/tests/tutorial/views.py b/docs/tutorials/wiki/src/tests/tutorial/views.py index 77956b1e3..57529ac8d 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/views.py +++ b/docs/tutorials/wiki/src/tests/tutorial/views.py @@ -8,12 +8,6 @@ from pyramid.view import ( forbidden_view_config, ) -from pyramid.security import ( - remember, - forget, - authenticated_userid, - ) - from .security import USERS from .models import Page @@ -45,7 +39,7 @@ def view_page(context, request): edit_url = request.resource_url(context, 'edit_page') return dict(page = context, content = content, edit_url = edit_url, - logged_in = authenticated_userid(request)) + logged_in = request.authenticated_userid) @view_config(name='add_page', context='.models.Wiki', renderer='templates/edit.pt', @@ -65,7 +59,7 @@ def add_page(context, request): page.__parent__ = context return dict(page=page, save_url=save_url, - logged_in=authenticated_userid(request)) + logged_in=request.authenticated_userid) @view_config(name='edit_page', context='.models.Page', renderer='templates/edit.pt', @@ -77,7 +71,7 @@ def edit_page(context, request): return dict(page=context, save_url=request.resource_url(context, 'edit_page'), - logged_in=authenticated_userid(request)) + logged_in=request.authenticated_userid) @view_config(context='.models.Wiki', name='login', renderer='templates/login.pt') @@ -95,9 +89,8 @@ def login(request): login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - headers = remember(request, login) - return HTTPFound(location = came_from, - headers = headers) + request.remember_userid(login) + return HTTPFound(location=came_from) message = 'Failed login' return dict( @@ -110,6 +103,5 @@ def login(request): @view_config(context='.models.Wiki', name='logout') def logout(request): - headers = forget(request) - return HTTPFound(location = request.resource_url(request.context), - headers = headers) + request.forget_userid() + return HTTPFound(location=request.resource_url(request.context)) -- cgit v1.2.3 From 3657ba974660677050fe4a62441c2073bd71203c Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:08:58 -0400 Subject: fix wiki2 tutorial wrt request-method security APIs --- docs/tutorials/wiki2/authorization.rst | 27 +++++++--------------- .../wiki2/src/authorization/tutorial/views.py | 22 ++++++------------ docs/tutorials/wiki2/src/tests/tutorial/views.py | 22 ++++++------------ 3 files changed, 22 insertions(+), 49 deletions(-) diff --git a/docs/tutorials/wiki2/authorization.rst b/docs/tutorials/wiki2/authorization.rst index 2b4263610..830cb0277 100644 --- a/docs/tutorials/wiki2/authorization.rst +++ b/docs/tutorials/wiki2/authorization.rst @@ -221,14 +221,14 @@ Add the following import statements to the head of ``tutorial/tutorial/views.py``: .. literalinclude:: src/authorization/tutorial/views.py - :lines: 9-16,18,24-25 + :lines: 9-12,19 :linenos: - :emphasize-lines: 3,6-9,11 + :emphasize-lines: 3,5 :language: python (Only the highlighted lines need to be added.) -:meth:`~pyramid.view.forbidden_view_config` will be used +:func:`~pyramid.view.forbidden_view_config` will be used to customize the default 403 Forbidden page. :meth:`~pyramid.request.Request.remember_userid` and :meth:`~pyramid.request.Request.forget_userid` help to create and @@ -237,7 +237,7 @@ expire an auth ticket cookie. Now add the ``login`` and ``logout`` views: .. literalinclude:: src/authorization/tutorial/views.py - :lines: 91-123 + :lines: 85-115 :linenos: :language: python @@ -274,17 +274,6 @@ added to ``views.py``. Return a logged_in flag to the renderer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Add the following line to the import at the head of -``tutorial/tutorial/views.py``: - -.. literalinclude:: src/authorization/tutorial/views.py - :lines: 14-18 - :linenos: - :emphasize-lines: 4 - :language: python - -(Only the highlighted line needs to be added.) - Add a ``logged_in`` parameter to the return value of ``view_page()``, ``edit_page()`` and ``add_page()``, like this: @@ -296,12 +285,12 @@ like this: return dict(page = page, content = content, edit_url = edit_url, - logged_in = authenticated_userid(request)) + logged_in = request.authenticated_userid) (Only the highlighted line needs to be added.) -The :meth:`~pyramid.security.authenticated_userid` method will return None -if the user is not authenticated. +The :attr:`~pyramid.request.Request.authenticated_userid` property will return +``None`` if the user is not authenticated. Add a "Logout" link when logged in ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -349,7 +338,7 @@ when we're done: .. literalinclude:: src/authorization/tutorial/views.py :linenos: - :emphasize-lines: 11,14-18,25,31,37,58,61,73,76,88,91-117,119-123 + :emphasize-lines: 11,19,25,31,52,55,67,70,82,85-115 :language: python (Only the highlighted lines need to be added.) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/views.py b/docs/tutorials/wiki2/src/authorization/tutorial/views.py index b6dbbf5f6..110d738c2 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/views.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/views.py @@ -11,12 +11,6 @@ from pyramid.view import ( forbidden_view_config, ) -from pyramid.security import ( - remember, - forget, - authenticated_userid, - ) - from .models import ( DBSession, Page, @@ -55,7 +49,7 @@ def view_page(request): content = wikiwords.sub(check, content) edit_url = request.route_url('edit_page', pagename=pagename) return dict(page=page, content=content, edit_url=edit_url, - logged_in=authenticated_userid(request)) + logged_in=request.authenticated_userid) @view_config(route_name='add_page', renderer='templates/edit.pt', permission='edit') @@ -70,7 +64,7 @@ def add_page(request): save_url = request.route_url('add_page', pagename=pagename) page = Page(name='', data='') return dict(page=page, save_url=save_url, - logged_in=authenticated_userid(request)) + logged_in=request.authenticated_userid) @view_config(route_name='edit_page', renderer='templates/edit.pt', permission='edit') @@ -85,7 +79,7 @@ def edit_page(request): return dict( page=page, save_url = request.route_url('edit_page', pagename=pagename), - logged_in=authenticated_userid(request), + logged_in=request.authenticated_userid, ) @view_config(route_name='login', renderer='templates/login.pt') @@ -103,9 +97,8 @@ def login(request): login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - headers = remember(request, login) - return HTTPFound(location = came_from, - headers = headers) + request.remember_userid(login) + return HTTPFound(location = came_from) message = 'Failed login' return dict( @@ -118,7 +111,6 @@ def login(request): @view_config(route_name='logout') def logout(request): - headers = forget(request) - return HTTPFound(location = request.route_url('view_wiki'), - headers = headers) + request.forget_userid() + return HTTPFound(location = request.route_url('view_wiki')) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views.py b/docs/tutorials/wiki2/src/tests/tutorial/views.py index b6dbbf5f6..110d738c2 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/views.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/views.py @@ -11,12 +11,6 @@ from pyramid.view import ( forbidden_view_config, ) -from pyramid.security import ( - remember, - forget, - authenticated_userid, - ) - from .models import ( DBSession, Page, @@ -55,7 +49,7 @@ def view_page(request): content = wikiwords.sub(check, content) edit_url = request.route_url('edit_page', pagename=pagename) return dict(page=page, content=content, edit_url=edit_url, - logged_in=authenticated_userid(request)) + logged_in=request.authenticated_userid) @view_config(route_name='add_page', renderer='templates/edit.pt', permission='edit') @@ -70,7 +64,7 @@ def add_page(request): save_url = request.route_url('add_page', pagename=pagename) page = Page(name='', data='') return dict(page=page, save_url=save_url, - logged_in=authenticated_userid(request)) + logged_in=request.authenticated_userid) @view_config(route_name='edit_page', renderer='templates/edit.pt', permission='edit') @@ -85,7 +79,7 @@ def edit_page(request): return dict( page=page, save_url = request.route_url('edit_page', pagename=pagename), - logged_in=authenticated_userid(request), + logged_in=request.authenticated_userid, ) @view_config(route_name='login', renderer='templates/login.pt') @@ -103,9 +97,8 @@ def login(request): login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - headers = remember(request, login) - return HTTPFound(location = came_from, - headers = headers) + request.remember_userid(login) + return HTTPFound(location = came_from) message = 'Failed login' return dict( @@ -118,7 +111,6 @@ def login(request): @view_config(route_name='logout') def logout(request): - headers = forget(request) - return HTTPFound(location = request.route_url('view_wiki'), - headers = headers) + request.forget_userid() + return HTTPFound(location = request.route_url('view_wiki')) -- cgit v1.2.3 From 2bdafd4b1abf983d6c0d4c504b58d74d05077523 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:10:22 -0400 Subject: rendering --- pyramid/security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyramid/security.py b/pyramid/security.py index b0fd38678..f86b7e43a 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -384,7 +384,7 @@ class AuthenticationAPIMixin(object): callback. These headers are used for 'remembering' a set of credentials implied by the data passed as ``principal`` and ``*kw`` using the current :term:`authentication policy`. Common usage might - look like so within the body of a view function:: + look like so within the body of a view function: .. code-block:: python -- cgit v1.2.3 From 63f7ae00096e1121504d43ddcbefbd1e5293a985 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:13:15 -0400 Subject: wording --- pyramid/security.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pyramid/security.py b/pyramid/security.py index f86b7e43a..f7750cbef 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -379,12 +379,13 @@ class AuthenticationAPIMixin(object): return policy.remember(self, principal, **kw) def remember_userid(self, principal, on_exception=False, **kw): - """ Sets a sequence of header tuples (e.g. ``[('Set-Cookie', - 'foo=abc')]``) on the response eventually returned using a response - callback. These headers are used for 'remembering' a set of - credentials implied by the data passed as ``principal`` and ``*kw`` - using the current :term:`authentication policy`. Common usage might - look like so within the body of a view function: + """ Using a response callback, sets authentication headers on the + response eventually returned by the view executed by this request + suitable for loggin a user in. These headers are used for + 'remembering' a set of credentials implied by the data passed as + ``principal`` and ``*kw`` using the current :term:`authentication + policy`. Common usage might look like so within the body of a view + function: .. code-block:: python @@ -430,10 +431,9 @@ class AuthenticationAPIMixin(object): return policy.forget(self) def forget_userid(self, on_exception=False): - """ Sets a sequence of header tuples (e.g. ``[('Set-Cookie', - 'foo=abc')]``) suitable for 'forgetting' the set of credentials - possessed by the currently authenticated user on the response based on - the current :term:`authentication policy`, using a response callback. + """ Using a response callback, sets authentication headers suitable for + logging a user out on the response returned by the view executed during + this request based on the current :term:`authentication policy`. If no :term:`authentication policy` is in use, this function will be a noop. -- cgit v1.2.3 From e1838557e6721b5b42f1267b134b626099703c2c Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:14:52 -0400 Subject: not methods, attrs --- docs/api/request.rst | 4 ++-- pyramid/security.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/api/request.rst b/docs/api/request.rst index 3d1fe020c..661cdfc91 100644 --- a/docs/api/request.rst +++ b/docs/api/request.rst @@ -171,7 +171,7 @@ A property which returns the userid of the currently authenticated user or ``None`` if there is no :term:`authentication policy` in effect or there is no currently authenticated user. This differs from - :meth:`~pyramid.request.Request.unauthenticated_userid`, because the + :attr:`~pyramid.request.Request.unauthenticated_userid`, because the effective authentication policy will have ensured that a record associated with the userid exists in persistent storage; if it has not, this value will be ``None``. @@ -184,7 +184,7 @@ verified) user id of the credentials present in the request. ``None`` if there is no :term:`authentication policy` in effect or there is no user data associated with the current request. This differs from - :meth:`~pyramid.request.Request.authenticated_userid`, because the + :attr:`~pyramid.request.Request.authenticated_userid`, because the effective authentication policy will not ensure that a record associated with the userid exists in persistent storage. Even if the userid does not exist in persistent storage, this value will be the value diff --git a/pyramid/security.py b/pyramid/security.py index f7750cbef..a5d9c52eb 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -345,9 +345,9 @@ class AuthenticationAPIMixin(object): id of the credentials present in the request. ``None`` if there is no :term:`authentication policy` in effect or there is no user data associated with the current request. This differs from - :func:`~pyramid.security.authenticated_userid`, because the effective - authentication policy will not ensure that a record associated with the - userid exists in persistent storage. + :attr:`~pyramid.request.Request.authenticated_userid`, because the + effective authentication policy will not ensure that a record + associated with the userid exists in persistent storage. .. versionadded:: 1.5 """ -- cgit v1.2.3 From a91c19837f5ce579ce2a5bf68ddee30cfaebe034 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:19:23 -0400 Subject: note deprecation --- docs/narr/threadlocals.rst | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/narr/threadlocals.rst b/docs/narr/threadlocals.rst index a90ee4905..afe56de3e 100644 --- a/docs/narr/threadlocals.rst +++ b/docs/narr/threadlocals.rst @@ -29,17 +29,16 @@ of a thread local or a global is usually just a way to avoid passing some value around between functions, which is itself usually a very bad idea, at least if code readability counts as an important concern. -For historical reasons, however, thread local variables are indeed -consulted by various :app:`Pyramid` API functions. For example, -the implementation of the :mod:`pyramid.security` function named -:func:`~pyramid.security.authenticated_userid` retrieves the thread -local :term:`application registry` as a matter of course to find an +For historical reasons, however, thread local variables are indeed consulted by +various :app:`Pyramid` API functions. For example, the implementation of the +:mod:`pyramid.security` function named +:func:`~pyramid.security.authenticated_userid` (deprecated as of 1.5) retrieves +the thread local :term:`application registry` as a matter of course to find an :term:`authentication policy`. It uses the -:func:`pyramid.threadlocal.get_current_registry` function to -retrieve the application registry, from which it looks up the -authentication policy; it then uses the authentication policy to -retrieve the authenticated user id. This is how :app:`Pyramid` -allows arbitrary authentication policies to be "plugged in". +:func:`pyramid.threadlocal.get_current_registry` function to retrieve the +application registry, from which it looks up the authentication policy; it then +uses the authentication policy to retrieve the authenticated user id. This is +how :app:`Pyramid` allows arbitrary authentication policies to be "plugged in". When they need to do so, :app:`Pyramid` internals use two API functions to retrieve the :term:`request` and :term:`application -- cgit v1.2.3 From 675e0d4cf01840740490c03a2e3704b0b7d98de3 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:24:34 -0400 Subject: convert remember/forget to request-method-based --- docs/quick_tutorial/authentication/tutorial/views.py | 17 +++++------------ docs/quick_tutorial/authorization/tutorial/views.py | 17 +++++------------ 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/docs/quick_tutorial/authentication/tutorial/views.py b/docs/quick_tutorial/authentication/tutorial/views.py index 3038b6d9b..240a23d3e 100644 --- a/docs/quick_tutorial/authentication/tutorial/views.py +++ b/docs/quick_tutorial/authentication/tutorial/views.py @@ -1,9 +1,4 @@ from pyramid.httpexceptions import HTTPFound -from pyramid.security import ( - remember, - forget, - authenticated_userid - ) from pyramid.view import ( view_config, view_defaults @@ -16,7 +11,7 @@ from .security import USERS class TutorialViews: def __init__(self, request): self.request = request - self.logged_in = authenticated_userid(request) + self.logged_in = request.authenticated_userid @view_config(route_name='home') def home(self): @@ -41,9 +36,8 @@ class TutorialViews: login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - headers = remember(request, login) - return HTTPFound(location=came_from, - headers=headers) + request.remember_userid(login) + return HTTPFound(location=came_from) message = 'Failed login' return dict( @@ -58,7 +52,6 @@ class TutorialViews: @view_config(route_name='logout') def logout(self): request = self.request - headers = forget(request) + request.forget_userid() url = request.route_url('home') - return HTTPFound(location=url, - headers=headers) + return HTTPFound(location=url) diff --git a/docs/quick_tutorial/authorization/tutorial/views.py b/docs/quick_tutorial/authorization/tutorial/views.py index 92c1946ba..2ce2c37b4 100644 --- a/docs/quick_tutorial/authorization/tutorial/views.py +++ b/docs/quick_tutorial/authorization/tutorial/views.py @@ -1,9 +1,4 @@ from pyramid.httpexceptions import HTTPFound -from pyramid.security import ( - remember, - forget, - authenticated_userid - ) from pyramid.view import ( view_config, view_defaults, @@ -17,7 +12,7 @@ from .security import USERS class TutorialViews: def __init__(self, request): self.request = request - self.logged_in = authenticated_userid(request) + self.logged_in = request.authenticated_userid @view_config(route_name='home') def home(self): @@ -43,9 +38,8 @@ class TutorialViews: login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - headers = remember(request, login) - return HTTPFound(location=came_from, - headers=headers) + request.remember_userid(login) + return HTTPFound(location=came_from) message = 'Failed login' return dict( @@ -60,7 +54,6 @@ class TutorialViews: @view_config(route_name='logout') def logout(self): request = self.request - headers = forget(request) + request.forget_userid() url = request.route_url('home') - return HTTPFound(location=url, - headers=headers) + return HTTPFound(location=url) -- cgit v1.2.3 From 3bd1fa5dd792d639615e5125b73caef8c65a0a30 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:28:53 -0400 Subject: new api --- docs/narr/testing.rst | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/narr/testing.rst b/docs/narr/testing.rst index 3f5d5ae6c..5a5bf8fad 100644 --- a/docs/narr/testing.rst +++ b/docs/narr/testing.rst @@ -214,11 +214,10 @@ function. .. code-block:: python :linenos: - from pyramid.security import has_permission from pyramid.httpexceptions import HTTPForbidden def view_fn(request): - if not has_permission('edit', request.context, request): + if request.has_permission('edit'): raise HTTPForbidden return {'greeting':'hello'} @@ -229,15 +228,16 @@ function. otherwise it would fail when run normally. Without doing anything special during a unit test, the call to -:meth:`~pyramid.request.Request.has_permission` in this view function will always -return a ``True`` value. When a :app:`Pyramid` application starts normally, -it will populate a :term:`application registry` using :term:`configuration -declaration` calls made against a :term:`Configurator`. But if this -application registry is not created and populated (e.g. by initializing the -configurator with an authorization policy), like when you invoke application -code via a unit test, :app:`Pyramid` API functions will tend to either fail -or return default results. So how do you test the branch of the code in this -view function that raises :exc:`~pyramid.httpexceptions.HTTPForbidden`? +:meth:`~pyramid.request.Request.has_permission` in this view function will +always return a ``True`` value. When a :app:`Pyramid` application starts +normally, it will populate a :term:`application registry` using +:term:`configuration declaration` calls made against a :term:`Configurator`. +But if this application registry is not created and populated (e.g. by +initializing the configurator with an authorization policy), like when you +invoke application code via a unit test, :app:`Pyramid` API functions will tend +to either fail or return default results. So how do you test the branch of the +code in this view function that raises +:exc:`~pyramid.httpexceptions.HTTPForbidden`? The testing API provided by :app:`Pyramid` allows you to simulate various application registry registrations for use under a unit testing framework @@ -287,12 +287,12 @@ Its third line registers a "dummy" "non-permissive" authorization policy using the :meth:`~pyramid.config.Configurator.testing_securitypolicy` method, which is a special helper method for unit testing. -We then create a :class:`pyramid.testing.DummyRequest` object which simulates -a WebOb request object API. A :class:`pyramid.testing.DummyRequest` is a -request object that requires less setup than a "real" :app:`Pyramid` request. -We call the function being tested with the manufactured request. When the -function is called, :func:`pyramid.security.has_permission` will call the -"dummy" authentication policy we've registered through +We then create a :class:`pyramid.testing.DummyRequest` object which simulates a +WebOb request object API. A :class:`pyramid.testing.DummyRequest` is a request +object that requires less setup than a "real" :app:`Pyramid` request. We call +the function being tested with the manufactured request. When the function is +called, :meth:`pyramid.request.Request.has_permission` will call the "dummy" +authentication policy we've registered through :meth:`~pyramid.config.Configurator.testing_securitypolicy`, which denies access. We check that the view function raises a :exc:`~pyramid.httpexceptions.HTTPForbidden` error. -- cgit v1.2.3 From 2885a7b96545c037109d7999319f74869a640050 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:40:12 -0400 Subject: fix failing test (unrelated to security stuff) --- docs/tutorials/wiki2/src/tests/tutorial/tests.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/tests.py b/docs/tutorials/wiki2/src/tests/tutorial/tests.py index 4ee30685e..c50e05b6d 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/tests.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/tests.py @@ -26,27 +26,6 @@ def _registerRoutes(config): config.add_route('add_page', 'add_page/{pagename}') -class PageModelTests(unittest.TestCase): - - def setUp(self): - self.session = _initTestingDB() - - def tearDown(self): - self.session.remove() - - def _getTargetClass(self): - from tutorial.models import Page - return Page - - def _makeOne(self, name='SomeName', data='some data'): - return self._getTargetClass()(name, data) - - def test_constructor(self): - instance = self._makeOne() - self.assertEqual(instance.name, 'SomeName') - self.assertEqual(instance.data, 'some data') - - class ViewWikiTests(unittest.TestCase): def setUp(self): self.config = testing.setUp() -- cgit v1.2.3 From 9a1e1ec235dd187e6aae675873688ab9b086b4ef Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:53:37 -0400 Subject: rendering --- pyramid/security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyramid/security.py b/pyramid/security.py index a5d9c52eb..7ffd37b8c 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -466,7 +466,7 @@ class AuthorizationAPIMixin(object): def has_permission(self, permission, context=None): """ Given a permission and an optional context, - returns an instance of :data:`pyramid.security.Allowed if the + returns an instance of :data:`pyramid.security.Allowed` if the permission is granted to this request with the provided context, or the context already associated with the request. Otherwise, returns an instance of :data:`pyramid.security.Denied`. -- cgit v1.2.3 From e0d1af934d85a34a83e2026976ddd05d92d21b5e Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:55:55 -0400 Subject: indicate default --- pyramid/security.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/pyramid/security.py b/pyramid/security.py index 7ffd37b8c..d754aeadf 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -465,15 +465,16 @@ class AuthenticationAPIMixin(object): class AuthorizationAPIMixin(object): def has_permission(self, permission, context=None): - """ Given a permission and an optional context, - returns an instance of :data:`pyramid.security.Allowed` if the - permission is granted to this request with the provided context, - or the context already associated with the request. Otherwise, - returns an instance of :data:`pyramid.security.Denied`. - This method delegates to the current authentication and - authorization policies. Returns :data:`pyramid.security.Allowed` - unconditionally if no authentication policy has been registered - for this request. + """ Given a permission and an optional context, returns an instance of + :data:`pyramid.security.Allowed` if the permission is granted to this + request with the provided context, or the context already associated + with the request. Otherwise, returns an instance of + :data:`pyramid.security.Denied`. This method delegates to the current + authentication and authorization policies. Returns + :data:`pyramid.security.Allowed` unconditionally if no authentication + policy has been registered for this request. If ``context`` is not + supplied or is supplied as ``None``, the context used is the + ``request.context`` attribute. :param permission: Does this request have the given permission? :type permission: unicode, str -- cgit v1.2.3 From c126033112e468cdf858c7c1ad0bb29e7f57f520 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 30 Oct 2013 20:57:24 -0400 Subject: indicate default --- pyramid/security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyramid/security.py b/pyramid/security.py index d754aeadf..7a65d22ce 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -478,7 +478,7 @@ class AuthorizationAPIMixin(object): :param permission: Does this request have the given permission? :type permission: unicode, str - :param context: Typically a resource of a regsitered type. + :param context: A resource object or ``None`` :type context: object :returns: `pyramid.security.PermitsResult` -- cgit v1.2.3 From 19d5fe09bb37d3694f63884eb5a95158f4252473 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 7 Nov 2013 00:00:38 -0600 Subject: document add_adapter --- docs/api/renderers.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api/renderers.rst b/docs/api/renderers.rst index ea000ad02..0caca02b4 100644 --- a/docs/api/renderers.rst +++ b/docs/api/renderers.rst @@ -13,8 +13,12 @@ .. autoclass:: JSON + .. automethod:: add_adapter + .. autoclass:: JSONP + .. automethod:: add_adapter + .. attribute:: null_renderer An object that can be used in advanced integration cases as input to the -- cgit v1.2.3 From cd5ab51eca5ca95c31eaa12192234938bc1435cb Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 7 Nov 2013 01:08:39 -0600 Subject: support query string and anchor on external static urls --- pyramid/config/views.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 69f68e422..3b768b0e9 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -36,6 +36,8 @@ from pyramid.interfaces import ( from pyramid import renderers from pyramid.compat import ( + native_, + text_type, string_types, urlparse, url_quote, @@ -44,6 +46,8 @@ from pyramid.compat import ( is_nonstr_iter ) +from pyramid.encode import urlencode + from pyramid.exceptions import ( ConfigurationError, PredicateMismatch, @@ -1902,7 +1906,18 @@ class StaticURLInfo(object): url = urlparse.urlunparse(url_parse( url, scheme=request.environ['wsgi.url_scheme'])) subpath = url_quote(subpath) - return urljoin(url, subpath) + result = urljoin(url, subpath) + if '_query' in kw: + query = kw.pop('_query') + if isinstance(query, text_type): + result += '?' + native_(query) + elif query: + result += '?' + urlencode(query, doseq=True) + if '_anchor' in kw: + anchor = kw.pop('_anchor') + anchor = native_(anchor, 'utf-8') + result += '#' + anchor + return result raise ValueError('No static URL definition matching %s' % path) -- cgit v1.2.3 From 86946fde64505c55424eed4cf891b8c4a98cefd3 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 7 Nov 2013 01:09:41 -0600 Subject: support encoding arbitrary query strings --- pyramid/url.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyramid/url.py b/pyramid/url.py index fda2c72c7..2e3542161 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -222,7 +222,9 @@ class URLMethodsMixin(object): if '_query' in kw: query = kw.pop('_query') - if query: + if isinstance(query, text_type): + qs = '?' + native_(query) + elif query: qs = '?' + urlencode(query, doseq=True) if '_anchor' in kw: @@ -580,7 +582,9 @@ class URLMethodsMixin(object): if 'query' in kw: query = kw['query'] - if query: + if isinstance(query, text_type): + qs = '?' + native_(query) + elif query: qs = '?' + urlencode(query, doseq=True) if 'anchor' in kw: -- cgit v1.2.3 From e967a9da69291d603a395550b0f30868a25c4ea5 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 7 Nov 2013 01:29:10 -0600 Subject: tests for custom query strings --- pyramid/encode.py | 13 +++++++------ pyramid/tests/test_url.py | 17 +++++++++++++++++ pyramid/url.py | 14 +++++++++----- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/pyramid/encode.py b/pyramid/encode.py index 9e190bc21..d2376109e 100644 --- a/pyramid/encode.py +++ b/pyramid/encode.py @@ -3,7 +3,7 @@ from pyramid.compat import ( binary_type, is_nonstr_iter, url_quote as _url_quote, - url_quote_plus as quote_plus, # bw compat api (dnr) + url_quote_plus as _quote_plus, ) def url_quote(s, safe=''): # bw compat api @@ -47,28 +47,29 @@ def urlencode(query, doseq=True): prefix = '' for (k, v) in query: - k = _enc(k) + k = quote_plus(k) if is_nonstr_iter(v): for x in v: - x = _enc(x) + x = quote_plus(x) result += '%s%s=%s' % (prefix, k, x) prefix = '&' elif v is None: result += '%s%s=' % (prefix, k) else: - v = _enc(v) + v = quote_plus(v) result += '%s%s=%s' % (prefix, k, v) prefix = '&' return result -def _enc(val): +# bw compat api (dnr) +def quote_plus(val): cls = val.__class__ if cls is text_type: val = val.encode('utf-8') elif cls is not binary_type: val = str(val).encode('utf-8') - return quote_plus(val) + return _quote_plus(val) diff --git a/pyramid/tests/test_url.py b/pyramid/tests/test_url.py index f6117777f..9cee7f61c 100644 --- a/pyramid/tests/test_url.py +++ b/pyramid/tests/test_url.py @@ -93,6 +93,14 @@ class TestURLMethodsMixin(unittest.TestCase): result = request.resource_url(context, 'a b c') self.assertEqual(result, 'http://example.com:5432/context/a%20b%20c') + def test_resource_url_with_query_str(self): + request = self._makeOne() + self._registerResourceURL(request.registry) + context = DummyContext() + result = request.resource_url(context, 'a', query='(openlayers)') + self.assertEqual(result, + 'http://example.com:5432/context/a?%28openlayers%29') + def test_resource_url_with_query_dict(self): request = self._makeOne() self._registerResourceURL(request.registry) @@ -483,6 +491,15 @@ class TestURLMethodsMixin(unittest.TestCase): self.assertEqual(result, 'http://example.com:5432/1/2/3?q=1') + def test_route_url_with_query_str(self): + from pyramid.interfaces import IRoutesMapper + request = self._makeOne() + mapper = DummyRoutesMapper(route=DummyRoute('/1/2/3')) + request.registry.registerUtility(mapper, IRoutesMapper) + result = request.route_url('flub', _query='(openlayers)') + self.assertEqual(result, + 'http://example.com:5432/1/2/3?%28openlayers%29') + def test_route_url_with_empty_query(self): from pyramid.interfaces import IRoutesMapper request = self._makeOne() diff --git a/pyramid/url.py b/pyramid/url.py index 2e3542161..4803283c3 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -14,10 +14,14 @@ from pyramid.interfaces import ( from pyramid.compat import ( native_, bytes_, + string_types, text_type, url_quote, ) -from pyramid.encode import urlencode +from pyramid.encode import ( + quote_plus, + urlencode, +) from pyramid.path import caller_package from pyramid.threadlocal import get_current_registry @@ -222,8 +226,8 @@ class URLMethodsMixin(object): if '_query' in kw: query = kw.pop('_query') - if isinstance(query, text_type): - qs = '?' + native_(query) + if isinstance(query, string_types): + qs = '?' + quote_plus(query) elif query: qs = '?' + urlencode(query, doseq=True) @@ -582,8 +586,8 @@ class URLMethodsMixin(object): if 'query' in kw: query = kw['query'] - if isinstance(query, text_type): - qs = '?' + native_(query) + if isinstance(query, string_types): + qs = '?' + quote_plus(query) elif query: qs = '?' + urlencode(query, doseq=True) -- cgit v1.2.3 From fc30e8e2bac083907f0fcda6fddfd30d0d48751f Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 7 Nov 2013 01:54:50 -0600 Subject: fix mishandled anchors that need to be quoted --- pyramid/config/views.py | 7 +++++-- pyramid/tests/test_config/test_views.py | 11 +++++++++++ pyramid/tests/test_url.py | 33 ++++++++------------------------- pyramid/url.py | 4 ++-- 4 files changed, 26 insertions(+), 29 deletions(-) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 3b768b0e9..190eefc98 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -46,7 +46,10 @@ from pyramid.compat import ( is_nonstr_iter ) -from pyramid.encode import urlencode +from pyramid.encode import ( + quote_plus, + urlencode, +) from pyramid.exceptions import ( ConfigurationError, @@ -1915,7 +1918,7 @@ class StaticURLInfo(object): result += '?' + urlencode(query, doseq=True) if '_anchor' in kw: anchor = kw.pop('_anchor') - anchor = native_(anchor, 'utf-8') + anchor = quote_plus(anchor) result += '#' + anchor return result diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 051961d25..a7e32f0c6 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -3820,6 +3820,17 @@ class TestStaticURLInfo(unittest.TestCase): result = inst.generate('package:path/abc def', request, a=1) self.assertEqual(result, 'http://example.com/abc%20def') + def test_generate_url_with_custom_anchor(self): + inst = self._makeOne() + registrations = [('http://example.com/', 'package:path/', None)] + inst._get_registrations = lambda *x: registrations + request = self._makeRequest() + uc = text_(b'La Pe\xc3\xb1a', 'utf-8') + result = inst.generate('package:path/abc def', request, a=1, + _anchor=uc) + self.assertEqual(result, + 'http://example.com/abc%20def#La+Pe%C3%B1a') + def test_add_already_exists(self): inst = self._makeOne() config = self._makeConfig( diff --git a/pyramid/tests/test_url.py b/pyramid/tests/test_url.py index 9cee7f61c..80dbd7001 100644 --- a/pyramid/tests/test_url.py +++ b/pyramid/tests/test_url.py @@ -157,15 +157,10 @@ class TestURLMethodsMixin(unittest.TestCase): request = self._makeOne() self._registerResourceURL(request.registry) context = DummyContext() - uc = text_(b'La Pe\xc3\xb1a', 'utf-8') + uc = text_(b'La Pe\xc3\xb1a', 'utf-8') result = request.resource_url(context, anchor=uc) - self.assertEqual( - result, - native_( - text_(b'http://example.com:5432/context/#La Pe\xc3\xb1a', - 'utf-8'), - 'utf-8') - ) + self.assertEqual(result, + 'http://example.com:5432/context/#La+Pe%C3%B1a') def test_resource_url_anchor_is_not_urlencoded(self): request = self._makeOne() @@ -173,7 +168,7 @@ class TestURLMethodsMixin(unittest.TestCase): context = DummyContext() result = request.resource_url(context, anchor=' /#') self.assertEqual(result, - 'http://example.com:5432/context/# /#') + 'http://example.com:5432/context/#+%2F%23') def test_resource_url_no_IResourceURL_registered(self): # falls back to ResourceURL @@ -456,14 +451,8 @@ class TestURLMethodsMixin(unittest.TestCase): request.registry.registerUtility(mapper, IRoutesMapper) result = request.route_url('flub', _anchor=b"La Pe\xc3\xb1a") - self.assertEqual( - result, - native_( - text_( - b'http://example.com:5432/1/2/3#La Pe\xc3\xb1a', - 'utf-8'), - 'utf-8') - ) + self.assertEqual(result, + 'http://example.com:5432/1/2/3#La+Pe%C3%B1a') def test_route_url_with_anchor_unicode(self): from pyramid.interfaces import IRoutesMapper @@ -473,14 +462,8 @@ class TestURLMethodsMixin(unittest.TestCase): anchor = text_(b'La Pe\xc3\xb1a', 'utf-8') result = request.route_url('flub', _anchor=anchor) - self.assertEqual( - result, - native_( - text_( - b'http://example.com:5432/1/2/3#La Pe\xc3\xb1a', - 'utf-8'), - 'utf-8') - ) + self.assertEqual(result, + 'http://example.com:5432/1/2/3#La+Pe%C3%B1a') def test_route_url_with_query(self): from pyramid.interfaces import IRoutesMapper diff --git a/pyramid/url.py b/pyramid/url.py index 4803283c3..eb9b364d9 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -233,7 +233,7 @@ class URLMethodsMixin(object): if '_anchor' in kw: anchor = kw.pop('_anchor') - anchor = native_(anchor, 'utf-8') + anchor = quote_plus(anchor) anchor = '#' + anchor if '_app_url' in kw: @@ -594,7 +594,7 @@ class URLMethodsMixin(object): if 'anchor' in kw: anchor = kw['anchor'] if isinstance(anchor, text_type): - anchor = native_(anchor, 'utf-8') + anchor = quote_plus(anchor) anchor = '#' + anchor if elements: -- cgit v1.2.3 From 8f3bf659488c1a946af2cb74c7a1fd4179c350de Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 7 Nov 2013 01:55:07 -0600 Subject: handle static urls generated with a query string --- pyramid/config/views.py | 2 +- pyramid/tests/test_config/test_views.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 190eefc98..022984420 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -1913,7 +1913,7 @@ class StaticURLInfo(object): if '_query' in kw: query = kw.pop('_query') if isinstance(query, text_type): - result += '?' + native_(query) + result += '?' + quote_plus(query) elif query: result += '?' + urlencode(query, doseq=True) if '_anchor' in kw: diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index a7e32f0c6..832921713 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -3820,6 +3820,16 @@ class TestStaticURLInfo(unittest.TestCase): result = inst.generate('package:path/abc def', request, a=1) self.assertEqual(result, 'http://example.com/abc%20def') + def test_generate_url_with_custom_query(self): + inst = self._makeOne() + registrations = [('http://example.com/', 'package:path/', None)] + inst._get_registrations = lambda *x: registrations + request = self._makeRequest() + result = inst.generate('package:path/abc def', request, a=1, + _query='(openlayers)') + self.assertEqual(result, + 'http://example.com/abc%20def?%28openlayers%29') + def test_generate_url_with_custom_anchor(self): inst = self._makeOne() registrations = [('http://example.com/', 'package:path/', None)] -- cgit v1.2.3 From 70381b1ac56348c025261f1e7300e0b63891d363 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Thu, 7 Nov 2013 19:24:20 -0600 Subject: fix tests --- pyramid/config/views.py | 4 +--- pyramid/tests/test_url.py | 2 +- pyramid/url.py | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 022984420..0a47d869a 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -36,8 +36,6 @@ from pyramid.interfaces import ( from pyramid import renderers from pyramid.compat import ( - native_, - text_type, string_types, urlparse, url_quote, @@ -1912,7 +1910,7 @@ class StaticURLInfo(object): result = urljoin(url, subpath) if '_query' in kw: query = kw.pop('_query') - if isinstance(query, text_type): + if isinstance(query, string_types): result += '?' + quote_plus(query) elif query: result += '?' + urlencode(query, doseq=True) diff --git a/pyramid/tests/test_url.py b/pyramid/tests/test_url.py index 80dbd7001..cbbb933a7 100644 --- a/pyramid/tests/test_url.py +++ b/pyramid/tests/test_url.py @@ -162,7 +162,7 @@ class TestURLMethodsMixin(unittest.TestCase): self.assertEqual(result, 'http://example.com:5432/context/#La+Pe%C3%B1a') - def test_resource_url_anchor_is_not_urlencoded(self): + def test_resource_url_anchor_is_urlencoded(self): request = self._makeOne() self._registerResourceURL(request.registry) context = DummyContext() diff --git a/pyramid/url.py b/pyramid/url.py index eb9b364d9..4384e9cd6 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -12,10 +12,8 @@ from pyramid.interfaces import ( ) from pyramid.compat import ( - native_, bytes_, string_types, - text_type, url_quote, ) from pyramid.encode import ( @@ -593,8 +591,7 @@ class URLMethodsMixin(object): if 'anchor' in kw: anchor = kw['anchor'] - if isinstance(anchor, text_type): - anchor = quote_plus(anchor) + anchor = quote_plus(anchor) anchor = '#' + anchor if elements: -- cgit v1.2.3 From 0dcd56c2c30863c6683c0cf442aa73dfdcd11b13 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sat, 9 Nov 2013 17:11:16 -0500 Subject: undeprecate remember/forget functions and remove remember_userid/forget_userid methods from request --- CHANGES.txt | 14 +- docs/api/request.rst | 7 +- .../authentication/tutorial/views.py | 15 +- .../quick_tutorial/authorization/tutorial/views.py | 15 +- docs/tutorials/wiki/authorization.rst | 19 +- .../wiki/src/authorization/tutorial/views.py | 16 +- docs/tutorials/wiki/src/tests/tutorial/views.py | 16 +- docs/tutorials/wiki2/authorization.rst | 16 +- .../wiki2/src/authorization/tutorial/views.py | 22 ++- docs/tutorials/wiki2/src/tests/tutorial/views.py | 23 ++- pyramid/security.py | 139 ++----------- pyramid/tests/test_config/test_testing.py | 32 +-- pyramid/tests/test_security.py | 214 ++++++--------------- 13 files changed, 183 insertions(+), 365 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 20b7726c4..9f780fe45 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,23 +4,13 @@ Unreleased Features -------- -- Authentication and authorization APIs have been added as as methods of the - request: ``request.has_permission``, ``request.forget_userid``, and - ``request.remember_userid``. +- An authorization API has been added as a method of the + request: ``request.has_permission``. ``request.has_permission`` is a method-based alternative to the ``pyramid.security.has_permission`` API and works exactly the same. The older API is now deprecated. - ``request.forget_userid`` and ``request.remember_userid`` are method-based - alternatives to ``pyramid.security.forget`` and - ``pyramid.security.remember``. These do not work exacly the same as their - function counterparts, however. These methods automatically set the headers - returned by the authentication policy on the response, whereas the older - function-based APIs returned a sequence of headers and required the caller to - set those headers. The older function-based API still works but is now - deprecated. - - Property API attributes have been added to the request for easier access to authentication data: ``request.authenticated_userid``, ``request.unauthenticated_userid``, and ``request.effective_principals``. diff --git a/docs/api/request.rst b/docs/api/request.rst index 661cdfc91..b7604020e 100644 --- a/docs/api/request.rst +++ b/docs/api/request.rst @@ -13,8 +13,7 @@ current_route_path, static_url, static_path, model_url, resource_url, set_property, effective_principals, authenticated_userid, - unauthenticated_userid, has_permission, forget_userid, - remember_userid + unauthenticated_userid, has_permission .. attribute:: context @@ -254,10 +253,6 @@ request provided by e.g. the ``pshell`` environment. For more information, see :ref:`subrequest_chapter`. - .. automethod:: remember_userid - - .. automethod:: forget_userid - .. automethod:: has_permission .. automethod:: add_response_callback diff --git a/docs/quick_tutorial/authentication/tutorial/views.py b/docs/quick_tutorial/authentication/tutorial/views.py index 240a23d3e..ab46eb2dd 100644 --- a/docs/quick_tutorial/authentication/tutorial/views.py +++ b/docs/quick_tutorial/authentication/tutorial/views.py @@ -1,4 +1,9 @@ from pyramid.httpexceptions import HTTPFound +from pyramid.security import ( + remember, + forget, + ) + from pyramid.view import ( view_config, view_defaults @@ -36,8 +41,9 @@ class TutorialViews: login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - request.remember_userid(login) - return HTTPFound(location=came_from) + headers = remember(request, login) + return HTTPFound(location=came_from, + headers=headers) message = 'Failed login' return dict( @@ -52,6 +58,7 @@ class TutorialViews: @view_config(route_name='logout') def logout(self): request = self.request - request.forget_userid() + headers = forget(request) url = request.route_url('home') - return HTTPFound(location=url) + return HTTPFound(location=url, + headers=headers) diff --git a/docs/quick_tutorial/authorization/tutorial/views.py b/docs/quick_tutorial/authorization/tutorial/views.py index 2ce2c37b4..43d14455a 100644 --- a/docs/quick_tutorial/authorization/tutorial/views.py +++ b/docs/quick_tutorial/authorization/tutorial/views.py @@ -1,4 +1,9 @@ from pyramid.httpexceptions import HTTPFound +from pyramid.security import ( + remember, + forget, + ) + from pyramid.view import ( view_config, view_defaults, @@ -38,8 +43,9 @@ class TutorialViews: login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - request.remember_userid(login) - return HTTPFound(location=came_from) + headers = remember(request, login) + return HTTPFound(location=came_from, + headers=headers) message = 'Failed login' return dict( @@ -54,6 +60,7 @@ class TutorialViews: @view_config(route_name='logout') def logout(self): request = self.request - request.forget_userid() + headers = forget(request) url = request.route_url('home') - return HTTPFound(location=url) + return HTTPFound(location=url, + headers=headers) diff --git a/docs/tutorials/wiki/authorization.rst b/docs/tutorials/wiki/authorization.rst index bba303d7f..62b1164e3 100644 --- a/docs/tutorials/wiki/authorization.rst +++ b/docs/tutorials/wiki/authorization.rst @@ -197,24 +197,24 @@ Add the following import statements to the head of ``tutorial/tutorial/views.py``: .. literalinclude:: src/authorization/tutorial/views.py - :lines: 6-11 + :lines: 6-13,15-17 :linenos: - :emphasize-lines: 3,6 + :emphasize-lines: 3,6-9,11 :language: python (Only the highlighted lines, with other necessary modifications, need to be added.) -:func:`~pyramid.view.forbidden_view_config` will be used +:meth:`~pyramid.view.forbidden_view_config` will be used to customize the default 403 Forbidden page. -:meth:`~pyramid.request.Request.remember_userid` and -:meth:`~pyramid.request.Request.forget_userid` help to create and +:meth:`~pyramid.security.remember` and +:meth:`~pyramid.security.forget` help to create and expire an auth ticket cookie. Now add the ``login`` and ``logout`` views: .. literalinclude:: src/authorization/tutorial/views.py - :lines: 76-102 + :lines: 82-120 :linenos: :language: python @@ -267,9 +267,8 @@ like this: (Only the highlighted line and a trailing comma on the preceding line need to be added.) -:attr:`~pyramid.request.Request.authenticated_userid` will return ``None`` -if the user is not authenticated, or a user id if the user is -authenticated. +The :meth:`pyramid.request.Request.authenticated_userid` will be ``None`` if +the user is not authenticated, or a user id if the user is authenticated. Add a "Logout" link when logged in ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -317,7 +316,7 @@ when we're done: .. literalinclude:: src/authorization/tutorial/views.py :linenos: - :emphasize-lines: 8,11,18,23,42,46,62,66,74,80,76-107 + :emphasize-lines: 8,11-15,17,24,29,48,52,68,72,80,82-120 :language: python (Only the highlighted lines need to be added.) diff --git a/docs/tutorials/wiki/src/authorization/tutorial/views.py b/docs/tutorials/wiki/src/authorization/tutorial/views.py index 57529ac8d..62e96e0e7 100644 --- a/docs/tutorials/wiki/src/authorization/tutorial/views.py +++ b/docs/tutorials/wiki/src/authorization/tutorial/views.py @@ -8,6 +8,12 @@ from pyramid.view import ( forbidden_view_config, ) +from pyramid.security import ( + remember, + forget, + ) + + from .security import USERS from .models import Page @@ -89,8 +95,9 @@ def login(request): login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - request.remember_userid(login) - return HTTPFound(location=came_from) + headers = remember(request, login) + return HTTPFound(location = came_from, + headers = headers) message = 'Failed login' return dict( @@ -103,5 +110,6 @@ def login(request): @view_config(context='.models.Wiki', name='logout') def logout(request): - request.forget_userid() - return HTTPFound(location=request.resource_url(request.context)) + headers = forget(request) + return HTTPFound(location = request.resource_url(request.context), + headers = headers) diff --git a/docs/tutorials/wiki/src/tests/tutorial/views.py b/docs/tutorials/wiki/src/tests/tutorial/views.py index 57529ac8d..62e96e0e7 100644 --- a/docs/tutorials/wiki/src/tests/tutorial/views.py +++ b/docs/tutorials/wiki/src/tests/tutorial/views.py @@ -8,6 +8,12 @@ from pyramid.view import ( forbidden_view_config, ) +from pyramid.security import ( + remember, + forget, + ) + + from .security import USERS from .models import Page @@ -89,8 +95,9 @@ def login(request): login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - request.remember_userid(login) - return HTTPFound(location=came_from) + headers = remember(request, login) + return HTTPFound(location = came_from, + headers = headers) message = 'Failed login' return dict( @@ -103,5 +110,6 @@ def login(request): @view_config(context='.models.Wiki', name='logout') def logout(request): - request.forget_userid() - return HTTPFound(location=request.resource_url(request.context)) + headers = forget(request) + return HTTPFound(location = request.resource_url(request.context), + headers = headers) diff --git a/docs/tutorials/wiki2/authorization.rst b/docs/tutorials/wiki2/authorization.rst index 830cb0277..1e5d0dcbf 100644 --- a/docs/tutorials/wiki2/authorization.rst +++ b/docs/tutorials/wiki2/authorization.rst @@ -221,23 +221,23 @@ Add the following import statements to the head of ``tutorial/tutorial/views.py``: .. literalinclude:: src/authorization/tutorial/views.py - :lines: 9-12,19 + :lines: 9-19 :linenos: - :emphasize-lines: 3,5 + :emphasize-lines: 3,6-9,11 :language: python (Only the highlighted lines need to be added.) -:func:`~pyramid.view.forbidden_view_config` will be used +:meth:`~pyramid.view.forbidden_view_config` will be used to customize the default 403 Forbidden page. -:meth:`~pyramid.request.Request.remember_userid` and -:meth:`~pyramid.request.Request.forget_userid` help to create and +:meth:`~pyramid.security.remember` and +:meth:`~pyramid.security.forget` help to create and expire an auth ticket cookie. Now add the ``login`` and ``logout`` views: .. literalinclude:: src/authorization/tutorial/views.py - :lines: 85-115 + :lines: 91-123 :linenos: :language: python @@ -289,7 +289,7 @@ like this: (Only the highlighted line needs to be added.) -The :attr:`~pyramid.request.Request.authenticated_userid` property will return +The :meth:`~pyramid.request.Request.authenticated_userid` property will be ``None`` if the user is not authenticated. Add a "Logout" link when logged in @@ -338,7 +338,7 @@ when we're done: .. literalinclude:: src/authorization/tutorial/views.py :linenos: - :emphasize-lines: 11,19,25,31,52,55,67,70,82,85-115 + :emphasize-lines: 11,14-19,25,31,37,58,61,73,76,88,91-117,119-123 :language: python (Only the highlighted lines need to be added.) diff --git a/docs/tutorials/wiki2/src/authorization/tutorial/views.py b/docs/tutorials/wiki2/src/authorization/tutorial/views.py index 110d738c2..e954d5a31 100644 --- a/docs/tutorials/wiki2/src/authorization/tutorial/views.py +++ b/docs/tutorials/wiki2/src/authorization/tutorial/views.py @@ -11,12 +11,18 @@ from pyramid.view import ( forbidden_view_config, ) +from pyramid.security import ( + remember, + forget, + ) + +from .security import USERS + from .models import ( DBSession, Page, ) -from .security import USERS # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @@ -78,8 +84,8 @@ def edit_page(request): pagename=pagename)) return dict( page=page, - save_url = request.route_url('edit_page', pagename=pagename), - logged_in=request.authenticated_userid, + save_url=request.route_url('edit_page', pagename=pagename), + logged_in=request.authenticated_userid ) @view_config(route_name='login', renderer='templates/login.pt') @@ -97,8 +103,9 @@ def login(request): login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - request.remember_userid(login) - return HTTPFound(location = came_from) + headers = remember(request, login) + return HTTPFound(location = came_from, + headers = headers) message = 'Failed login' return dict( @@ -111,6 +118,7 @@ def login(request): @view_config(route_name='logout') def logout(request): - request.forget_userid() - return HTTPFound(location = request.route_url('view_wiki')) + headers = forget(request) + return HTTPFound(location = request.route_url('view_wiki'), + headers = headers) diff --git a/docs/tutorials/wiki2/src/tests/tutorial/views.py b/docs/tutorials/wiki2/src/tests/tutorial/views.py index 110d738c2..41bea4785 100644 --- a/docs/tutorials/wiki2/src/tests/tutorial/views.py +++ b/docs/tutorials/wiki2/src/tests/tutorial/views.py @@ -11,12 +11,18 @@ from pyramid.view import ( forbidden_view_config, ) +from pyramid.security import ( + remember, + forget, + ) + +from .security import USERS + from .models import ( DBSession, Page, ) -from .security import USERS # regular expression used to find WikiWords wikiwords = re.compile(r"\b([A-Z]\w+[A-Z]+\w+)") @@ -78,8 +84,8 @@ def edit_page(request): pagename=pagename)) return dict( page=page, - save_url = request.route_url('edit_page', pagename=pagename), - logged_in=request.authenticated_userid, + save_url=request.route_url('edit_page', pagename=pagename), + logged_in=request.authenticated_userid ) @view_config(route_name='login', renderer='templates/login.pt') @@ -97,8 +103,9 @@ def login(request): login = request.params['login'] password = request.params['password'] if USERS.get(login) == password: - request.remember_userid(login) - return HTTPFound(location = came_from) + headers = remember(request, login) + return HTTPFound(location = came_from, + headers = headers) message = 'Failed login' return dict( @@ -111,6 +118,6 @@ def login(request): @view_config(route_name='logout') def logout(request): - request.forget_userid() - return HTTPFound(location = request.route_url('view_wiki')) - + headers = forget(request) + return HTTPFound(location = request.route_url('view_wiki'), + headers = headers) diff --git a/pyramid/security.py b/pyramid/security.py index 7a65d22ce..58fa9332a 100644 --- a/pyramid/security.py +++ b/pyramid/security.py @@ -38,6 +38,10 @@ def _get_registry(request): reg = get_current_registry() # b/c return reg +def _get_authentication_policy(request): + registry = _get_registry(request) + return registry.queryUtility(IAuthenticationPolicy) + def has_permission(permission, context, request): """ A function that calls @@ -113,8 +117,8 @@ deprecated( def remember(request, principal, **kw): """ - Returns a sequence of header tuples (e.g. ``[('Set-Cookie', - 'foo=abc')]``) on this request's response. + Returns a sequence of header tuples (e.g. ``[('Set-Cookie', 'foo=abc')]``) + on this request's response. These headers are suitable for 'remembering' a set of credentials implied by the data passed as ``principal`` and ``*kw`` using the current :term:`authentication policy`. Common usage might look @@ -126,6 +130,7 @@ def remember(request, principal, **kw): from pyramid.security import remember headers = remember(request, 'chrism', password='123', max_age='86400') + response = request.response response.headerlist.extend(headers) return response @@ -133,23 +138,11 @@ def remember(request, principal, **kw): always return an empty sequence. If used, the composition and meaning of ``**kw`` must be agreed upon by the calling code and the effective authentication policy. - - .. deprecated:: 1.5 - Use :meth:`pyramid.request.Request.remember_userid` instead. - but be sure to read its docs first; the remember_userid method is not an - exact analog of the remember function, because it sets headers instead - of returning them. """ - return request._remember_userid(principal, **kw) - -deprecated( - 'remember', - 'As of Pyramid 1.5 the "pyramid.security.remember" API is ' - 'now deprecated. It will be removed in Pyramd 1.8. Use the ' - '"remember_userid" method of the Pyramid request instead, but be sure to ' - 'read the docs first; the remember_userid method is not an exact analog of ' - 'the remember function, because it sets headers instead of returning them.' - ) + policy = _get_authentication_policy(request) + if policy is None: + return [] + return policy.remember(request, principal, **kw) def forget(request): """ @@ -169,21 +162,12 @@ def forget(request): always return an empty sequence. .. deprecated:: 1.5 - Use :meth:`pyramid.request.Request.forget_userid` instead. - but be sure to read its docs first; the forget_userid method is not an - exact analog of the forget function, because it sets headers instead - of returning them. + Use :meth:`pyramid.request.Request.get_logout_headers` instead. """ - return request._forget_userid() - -deprecated( - 'forget', - 'As of Pyramid 1.5 the "pyramid.security.forget" API is ' - 'now deprecated. It will be removed in Pyramd 1.8. Use the ' - '"forget_user" method of the Pyramid request instead, but be sure to ' - 'read the docs first; the forget_userid method is not an exact analog of ' - 'the forget function, because it sets headers instead of returning them.' - ) + policy = _get_authentication_policy(request) + if policy is None: + return [] + return policy.forget(request) def principals_allowed_by_permission(context, permission): """ Provided a ``context`` (a resource object), and a ``permission`` @@ -371,97 +355,6 @@ class AuthenticationAPIMixin(object): return [Everyone] return policy.effective_principals(self) - # b/c - def _remember_userid(self, principal, **kw): - policy = self._get_authentication_policy() - if policy is None: - return [] - return policy.remember(self, principal, **kw) - - def remember_userid(self, principal, on_exception=False, **kw): - """ Using a response callback, sets authentication headers on the - response eventually returned by the view executed by this request - suitable for loggin a user in. These headers are used for - 'remembering' a set of credentials implied by the data passed as - ``principal`` and ``*kw`` using the current :term:`authentication - policy`. Common usage might look like so within the body of a view - function: - - .. code-block:: python - - request.remember_userid('chrism', password='123', max_age='86400') - - This method always returns ``None``; it is called only for its side - effects. - - If no :term:`authentication policy` is in use, this function will - do nothing. If used, the composition and - meaning of ``**kw`` must be agreed upon by the calling code and - the effective authentication policy. - - One special keyword value is understood by this method: - ``on_exception``. Usually if an exception occurs within the same - request after this method is called, the headers provided by the - authentication policy will not be set on the response. If - ``on_exception`` is passed, and as ``True``, then the headers will be - set on the response even if an exception is later raised. By default - this value is ``False``. - - .. versionadded:: 1.5 - - """ - def callback(req, response): - # do not set the headers on an exception unless explicitly - # instructed - exc = getattr(req, 'exception', None) - if exc is None or on_exception: - # NB: this call to _remember_userid should be exactly here - # because some policies actually add another response callback - # when their remember method is called, and we dont want them - # to do that if there's an exception in the default case. - headers = req._remember_userid(principal, **kw) - response.headerlist.extend(headers) - self.add_response_callback(callback) - - # b/c - def _forget_userid(self): - policy = self._get_authentication_policy() - if policy is None: - return [] - return policy.forget(self) - - def forget_userid(self, on_exception=False): - """ Using a response callback, sets authentication headers suitable for - logging a user out on the response returned by the view executed during - this request based on the current :term:`authentication policy`. - - If no :term:`authentication policy` is in use, this function will - be a noop. - - This method always returns ``None``; it is called only for its side - effects. - - One special keyword value is understood by this method: - ``on_exception``. Usually if an exception occurs within the same - request after this method is called, the headers provided by the - authentication policy will not be set on the response. If - ``on_exception`` is passed, and as ``True``, then the headers will be - set on the response even if an exception is later raised. By default - this value is ``False``. - - .. versionadded:: 1.5 - """ - def callback(req, response): - exc = getattr(req, 'exception', None) - if exc is None or on_exception: - # NB: this call to _forget_userid should be exactly here - # because some policies actually add another response callback - # when their forget method is called, and we dont want them - # to do that if there's an exception in the default case. - headers = req._forget_userid() - response.headerlist.extend(headers) - self.add_response_callback(callback) - class AuthorizationAPIMixin(object): def has_permission(self, permission, context=None): diff --git a/pyramid/tests/test_config/test_testing.py b/pyramid/tests/test_config/test_testing.py index d13cb9285..05561bfe9 100644 --- a/pyramid/tests/test_config/test_testing.py +++ b/pyramid/tests/test_config/test_testing.py @@ -25,31 +25,28 @@ class TestingConfiguratorMixinTests(unittest.TestCase): self.assertEqual(ut.permissive, False) def test_testing_securitypolicy_remember_result(self): + from pyramid.security import remember config = self._makeOne(autocommit=True) pol = config.testing_securitypolicy( 'user', ('group1', 'group2'), - permissive=False, - remember_result=[('X-Pyramid-Test', True)]) + permissive=False, remember_result=True) request = DummyRequest() request.registry = config.registry - request.remember_userid('fred') + val = remember(request, 'fred') self.assertEqual(pol.remembered, 'fred') - val = dict(request.response.headerlist).get('X-Pyramid-Test') self.assertEqual(val, True) def test_testing_securitypolicy_forget_result(self): + from pyramid.security import forget config = self._makeOne(autocommit=True) pol = config.testing_securitypolicy( 'user', ('group1', 'group2'), - permissive=False, - forget_result=[('X-Pyramid-Test', True)]) + permissive=False, forget_result=True) request = DummyRequest() request.registry = config.registry - request.response = DummyResponse() - request.forget_userid() + val = forget(request) self.assertEqual(pol.forgotten, True) - val = dict(request.response.headerlist).get('X-Pyramid-Test') - self.assertTrue(val) + self.assertEqual(val, True) def test_testing_resources(self): from pyramid.traversal import find_resource @@ -200,24 +197,9 @@ from zope.interface import implementer class DummyEvent: pass -class DummyResponse(object): - def __init__(self): - self.headers = [] - - @property - def headerlist(self): - return self.headers - class DummyRequest(AuthenticationAPIMixin, AuthorizationAPIMixin): - subpath = () - matchdict = None def __init__(self, environ=None): if environ is None: environ = {} self.environ = environ - self.params = {} - self.cookies = {} - self.response = DummyResponse() - def add_response_callback(self, callback): - callback(self, self.response) diff --git a/pyramid/tests/test_security.py b/pyramid/tests/test_security.py index 96f171324..6f08a100c 100644 --- a/pyramid/tests/test_security.py +++ b/pyramid/tests/test_security.py @@ -127,6 +127,69 @@ class TestPrincipalsAllowedByPermission(unittest.TestCase): result = self._callFUT(context, 'view') self.assertEqual(result, 'yo') +class TestRemember(unittest.TestCase): + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + + def _callFUT(self, *arg): + from pyramid.security import remember + return remember(*arg) + + def test_no_authentication_policy(self): + request = _makeRequest() + result = self._callFUT(request, 'me') + self.assertEqual(result, []) + + def test_with_authentication_policy(self): + request = _makeRequest() + registry = request.registry + _registerAuthenticationPolicy(registry, 'yo') + result = self._callFUT(request, 'me') + self.assertEqual(result, [('X-Pyramid-Test', 'me')]) + + def test_with_authentication_policy_no_reg_on_request(self): + from pyramid.threadlocal import get_current_registry + registry = get_current_registry() + request = _makeRequest() + del request.registry + _registerAuthenticationPolicy(registry, 'yo') + result = self._callFUT(request, 'me') + self.assertEqual(result, [('X-Pyramid-Test', 'me')]) + +class TestForget(unittest.TestCase): + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + + def _callFUT(self, *arg): + from pyramid.security import forget + return forget(*arg) + + def test_no_authentication_policy(self): + request = _makeRequest() + result = self._callFUT(request) + self.assertEqual(result, []) + + def test_with_authentication_policy(self): + request = _makeRequest() + _registerAuthenticationPolicy(request.registry, 'yo') + result = self._callFUT(request) + self.assertEqual(result, [('X-Pyramid-Test', 'logout')]) + + def test_with_authentication_policy_no_reg_on_request(self): + from pyramid.threadlocal import get_current_registry + registry = get_current_registry() + request = _makeRequest() + del request.registry + _registerAuthenticationPolicy(registry, 'yo') + result = self._callFUT(request) + self.assertEqual(result, [('X-Pyramid-Test', 'logout')]) + class TestViewExecutionPermitted(unittest.TestCase): def setUp(self): testing.setUp() @@ -312,149 +375,6 @@ class TestEffectivePrincipals(unittest.TestCase): _registerAuthenticationPolicy(registry, 'yo') self.assertEqual(request.effective_principals, 'yo') -class TestRememberUserId(unittest.TestCase): - principal = 'the4th' - - def setUp(self): - testing.setUp() - - def tearDown(self): - testing.tearDown() - - def assert_response_headers(self, request, expected_headers): - request._process_response_callbacks(request.response) - headers = request.response.headerlist - self.assertEqual(list(expected_headers), list(headers)) - - def test_backward_compat_delegates_to_mixin(self): - from zope.deprecation import __show__ - try: - __show__.off() - request = _makeFakeRequest() - from pyramid.security import remember - self.assertEqual( - remember(request, 'matt'), - [('X-Pyramid-Test', 'remember_userid')] - ) - finally: - __show__.on() - - def test_with_no_authentication_policy(self): - request = _makeRequest() - headers_before = request.response.headerlist - request.remember_userid(self.principal) - self.assert_response_headers(request, headers_before) - - def test_with_authentication_policy(self): - request = _makeRequest() - headers_before = request.response.headerlist - expected_headers = headers_before[:] + [(_TEST_HEADER, self.principal)] - _registerAuthenticationPolicy(request.registry, self.principal) - request.remember_userid(self.principal) - self.assert_response_headers(request, expected_headers) - - def test_with_authentication_policy_no_reg_on_request(self): - from pyramid.threadlocal import get_current_registry - registry = get_current_registry() - request = _makeRequest() - del request.registry - _registerAuthenticationPolicy(registry, self.principal) - headers_before = request.response.headerlist - request.remember_userid(self.principal) - expected_headers = headers_before[:] + [(_TEST_HEADER, self.principal)] - self.assert_response_headers(request, expected_headers) - - def test_request_has_exception_attr_no_on_exception_flag(self): - request = _makeRequest() - headers_before = request.response.headerlist - _registerAuthenticationPolicy(request.registry, self.principal) - request.exception = True - request.remember_userid(self.principal) - self.assert_response_headers(request, headers_before) - - def test_request_has_exception_attr_with_on_exception_flag(self): - request = _makeRequest() - headers_before = request.response.headerlist - _registerAuthenticationPolicy(request.registry, self.principal) - request.exception = True - request.remember_userid(self.principal, on_exception=True) - expected_headers = headers_before[:] + [(_TEST_HEADER, self.principal)] - self.assert_response_headers(request, expected_headers) - -class TestForgetUserId(unittest.TestCase): - principal = 'me-not' - - def setUp(self): - testing.setUp() - - def tearDown(self): - testing.tearDown() - - def assert_response_headers(self, request, expected_headers): - request._process_response_callbacks(request.response) - headers = request.response.headerlist - self.assertEqual(list(expected_headers), list(headers)) - - def _makeOne(self): - request = _makeRequest() - request.response.headers.add(_TEST_HEADER, self.principal) - return request - - def test_backward_compat_delegates_to_mixin(self): - from zope.deprecation import __show__ - try: - __show__.off() - request = _makeFakeRequest() - from pyramid.security import forget - self.assertEqual( - forget(request), - [('X-Pyramid-Test', 'forget_userid')], - ) - finally: - __show__.on() - - def test_with_no_authentication_policy(self): - request = self._makeOne() - headers_before = request.response.headerlist - request.forget_userid() - self.assert_response_headers(request, headers_before) - - def test_with_authentication_policy(self): - request = self._makeOne() - headers_before = request.response.headerlist - expected_headers = headers_before[:] + [(_TEST_HEADER, 'forget_userid')] - _registerAuthenticationPolicy(request.registry, self.principal) - request.forget_userid() - self.assert_response_headers(request, expected_headers) - - def test_with_authentication_policy_no_reg_on_request(self): - from pyramid.threadlocal import get_current_registry - registry = get_current_registry() - request = self._makeOne() - del request.registry - _registerAuthenticationPolicy(registry, self.principal) - headers_before = request.response.headerlist - request.forget_userid() - expected_headers = headers_before[:] + [(_TEST_HEADER, 'forget_userid')] - self.assert_response_headers(request, expected_headers) - - def test_request_has_exception_attr_no_on_exception_flag(self): - request = self._makeOne() - headers_before = request.response.headerlist - _registerAuthenticationPolicy(request.registry, self.principal) - request.exception = True - request.forget_userid() - self.assert_response_headers(request, headers_before) - - def test_request_has_exception_attr_with_on_exception_flag(self): - request = self._makeOne() - headers_before = request.response.headerlist - _registerAuthenticationPolicy(request.registry, self.principal) - request.exception = True - request.forget_userid(on_exception=True) - expected_headers = headers_before[:] + [(_TEST_HEADER, 'forget_userid')] - self.assert_response_headers(request, expected_headers) - class TestHasPermission(unittest.TestCase): def setUp(self): testing.setUp() @@ -548,7 +468,7 @@ class DummyAuthenticationPolicy: return headers def forget(self, request): - headers = [(_TEST_HEADER, 'forget_userid')] + headers = [(_TEST_HEADER, 'logout')] self._header_forgotten = headers[0] return headers @@ -595,11 +515,5 @@ def _makeFakeRequest(): def effective_principals(req): return 'effective_principals' - def _forget_userid(req): - return [('X-Pyramid-Test', 'forget_userid')] - - def _remember_userid(req, principal, **kw): - return [('X-Pyramid-Test', 'remember_userid')] - return FakeRequest({}) -- cgit v1.2.3 From daa0964624bb4d2818cad62351418c09a7326b5a Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Mon, 11 Nov 2013 23:23:58 -0600 Subject: easiest commit ever --- docs/narr/paste.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/narr/paste.rst b/docs/narr/paste.rst index 3427b6d53..f1fb70869 100644 --- a/docs/narr/paste.rst +++ b/docs/narr/paste.rst @@ -87,7 +87,7 @@ configuration object and *returns* an instance of our application. .. _defaults_section_of_pastedeploy_file: -``[DEFAULTS]`` Section of a PasteDeploy ``.ini`` File +``[DEFAULT]`` Section of a PasteDeploy ``.ini`` File ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You can add a ``[DEFAULT]`` section to your PasteDeploy ``.ini`` file. Such -- cgit v1.2.3 From 093127f6f0492343521d6a07475178c0efb0fd98 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Tue, 12 Nov 2013 00:24:08 -0600 Subject: remove need for parsing static url twice to replace scheme --- pyramid/config/views.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 0a47d869a..5ad235795 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -1902,10 +1902,8 @@ class StaticURLInfo(object): else: parsed = url_parse(url) if not parsed.scheme: - # parsed.scheme is readonly, so we have to parse again - # to change the scheme, sigh. - url = urlparse.urlunparse(url_parse( - url, scheme=request.environ['wsgi.url_scheme'])) + url = urlparse.urlunparse(parsed._replace( + scheme=request.environ['wsgi.url_scheme'])) subpath = url_quote(subpath) result = urljoin(url, subpath) if '_query' in kw: -- cgit v1.2.3 From af3134a984a7e9c53d41607dcf4f1feb60282f85 Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Tue, 12 Nov 2013 00:56:57 -0600 Subject: centralize and properly escape query string and anchor arguments --- pyramid/config/views.py | 16 +++---- pyramid/encode.py | 4 +- pyramid/tests/test_config/test_views.py | 2 +- pyramid/tests/test_url.py | 10 ++--- pyramid/url.py | 77 +++++++++++++++++++-------------- 5 files changed, 58 insertions(+), 51 deletions(-) diff --git a/pyramid/config/views.py b/pyramid/config/views.py index 5ad235795..0165f96f1 100644 --- a/pyramid/config/views.py +++ b/pyramid/config/views.py @@ -70,6 +70,8 @@ from pyramid.security import NO_PERMISSION_REQUIRED from pyramid.static import static_view from pyramid.threadlocal import get_current_registry +from pyramid.url import parse_url_overrides + from pyramid.view import ( render_view_to_response, AppendSlashNotFoundViewFactory, @@ -1900,23 +1902,15 @@ class StaticURLInfo(object): kw['subpath'] = subpath return request.route_url(route_name, **kw) else: + app_url, scheme, host, port, qs, anchor = \ + parse_url_overrides(kw) parsed = url_parse(url) if not parsed.scheme: url = urlparse.urlunparse(parsed._replace( scheme=request.environ['wsgi.url_scheme'])) subpath = url_quote(subpath) result = urljoin(url, subpath) - if '_query' in kw: - query = kw.pop('_query') - if isinstance(query, string_types): - result += '?' + quote_plus(query) - elif query: - result += '?' + urlencode(query, doseq=True) - if '_anchor' in kw: - anchor = kw.pop('_anchor') - anchor = quote_plus(anchor) - result += '#' + anchor - return result + return result + qs + anchor raise ValueError('No static URL definition matching %s' % path) diff --git a/pyramid/encode.py b/pyramid/encode.py index d2376109e..15da1c511 100644 --- a/pyramid/encode.py +++ b/pyramid/encode.py @@ -65,11 +65,11 @@ def urlencode(query, doseq=True): return result # bw compat api (dnr) -def quote_plus(val): +def quote_plus(val, safe=''): cls = val.__class__ if cls is text_type: val = val.encode('utf-8') elif cls is not binary_type: val = str(val).encode('utf-8') - return _quote_plus(val) + return _quote_plus(val, safe=safe) diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index 832921713..c722c9166 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -3828,7 +3828,7 @@ class TestStaticURLInfo(unittest.TestCase): result = inst.generate('package:path/abc def', request, a=1, _query='(openlayers)') self.assertEqual(result, - 'http://example.com/abc%20def?%28openlayers%29') + 'http://example.com/abc%20def?(openlayers)') def test_generate_url_with_custom_anchor(self): inst = self._makeOne() diff --git a/pyramid/tests/test_url.py b/pyramid/tests/test_url.py index cbbb933a7..6a8624a9f 100644 --- a/pyramid/tests/test_url.py +++ b/pyramid/tests/test_url.py @@ -99,7 +99,7 @@ class TestURLMethodsMixin(unittest.TestCase): context = DummyContext() result = request.resource_url(context, 'a', query='(openlayers)') self.assertEqual(result, - 'http://example.com:5432/context/a?%28openlayers%29') + 'http://example.com:5432/context/a?(openlayers)') def test_resource_url_with_query_dict(self): request = self._makeOne() @@ -162,13 +162,13 @@ class TestURLMethodsMixin(unittest.TestCase): self.assertEqual(result, 'http://example.com:5432/context/#La+Pe%C3%B1a') - def test_resource_url_anchor_is_urlencoded(self): + def test_resource_url_anchor_is_urlencoded_safe(self): request = self._makeOne() self._registerResourceURL(request.registry) context = DummyContext() - result = request.resource_url(context, anchor=' /#') + result = request.resource_url(context, anchor=' /#?&+') self.assertEqual(result, - 'http://example.com:5432/context/#+%2F%23') + 'http://example.com:5432/context/#+/%23?&+') def test_resource_url_no_IResourceURL_registered(self): # falls back to ResourceURL @@ -481,7 +481,7 @@ class TestURLMethodsMixin(unittest.TestCase): request.registry.registerUtility(mapper, IRoutesMapper) result = request.route_url('flub', _query='(openlayers)') self.assertEqual(result, - 'http://example.com:5432/1/2/3?%28openlayers%29') + 'http://example.com:5432/1/2/3?(openlayers)') def test_route_url_with_empty_query(self): from pyramid.interfaces import IRoutesMapper diff --git a/pyramid/url.py b/pyramid/url.py index 4384e9cd6..e760bb356 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -29,6 +29,48 @@ from pyramid.traversal import ( ) PATH_SAFE = '/:@&+$,' # from webob +QUERY_SAFE = '/?:@!$&\'()*+,;=' # RFC 3986 +ANCHOR_SAFE = QUERY_SAFE + +def parse_url_overrides(kw): + """Parse special arguments passed when generating urls. + + The supplied dictionary is mutated, popping arguments as necessary. + Returns a 6-tuple of the format ``(app_url, scheme, host, port, + qs, anchor)``. + """ + anchor = '' + qs = '' + app_url = None + host = None + scheme = None + port = None + + if '_query' in kw: + query = kw.pop('_query') + if isinstance(query, string_types): + qs = '?' + quote_plus(query, safe=QUERY_SAFE) + elif query: + qs = '?' + urlencode(query, doseq=True) + + if '_anchor' in kw: + anchor = kw.pop('_anchor') + anchor = quote_plus(anchor, safe=ANCHOR_SAFE) + anchor = '#' + anchor + + if '_app_url' in kw: + app_url = kw.pop('_app_url') + + if '_host' in kw: + host = kw.pop('_host') + + if '_scheme' in kw: + scheme = kw.pop('_scheme') + + if '_port' in kw: + port = kw.pop('_port') + + return app_url, scheme, host, port, qs, anchor class URLMethodsMixin(object): """ Request methods mixin for BaseRequest having to do with URL @@ -215,36 +257,7 @@ class URLMethodsMixin(object): if route.pregenerator is not None: elements, kw = route.pregenerator(self, elements, kw) - anchor = '' - qs = '' - app_url = None - host = None - scheme = None - port = None - - if '_query' in kw: - query = kw.pop('_query') - if isinstance(query, string_types): - qs = '?' + quote_plus(query) - elif query: - qs = '?' + urlencode(query, doseq=True) - - if '_anchor' in kw: - anchor = kw.pop('_anchor') - anchor = quote_plus(anchor) - anchor = '#' + anchor - - if '_app_url' in kw: - app_url = kw.pop('_app_url') - - if '_host' in kw: - host = kw.pop('_host') - - if '_scheme' in kw: - scheme = kw.pop('_scheme') - - if '_port' in kw: - port = kw.pop('_port') + app_url, scheme, host, port, qs, anchor = parse_url_overrides(kw) if app_url is None: if (scheme is not None or host is not None or port is not None): @@ -585,13 +598,13 @@ class URLMethodsMixin(object): if 'query' in kw: query = kw['query'] if isinstance(query, string_types): - qs = '?' + quote_plus(query) + qs = '?' + quote_plus(query, safe=QUERY_SAFE) elif query: qs = '?' + urlencode(query, doseq=True) if 'anchor' in kw: anchor = kw['anchor'] - anchor = quote_plus(anchor) + anchor = quote_plus(anchor, safe=ANCHOR_SAFE) anchor = '#' + anchor if elements: -- cgit v1.2.3 From 22f0ebbc04f1fa03139ca7c99e02e39a2635590f Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Tue, 12 Nov 2013 01:13:48 -0600 Subject: modify quoting to be bare-bones --- pyramid/encode.py | 10 +++++++--- pyramid/tests/test_config/test_views.py | 2 +- pyramid/tests/test_url.py | 8 ++++---- pyramid/url.py | 11 +++++------ 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/pyramid/encode.py b/pyramid/encode.py index 15da1c511..0be0107b3 100644 --- a/pyramid/encode.py +++ b/pyramid/encode.py @@ -6,8 +6,13 @@ from pyramid.compat import ( url_quote_plus as _quote_plus, ) -def url_quote(s, safe=''): # bw compat api - return _url_quote(s, safe=safe) +def url_quote(val, safe=''): # bw compat api + cls = val.__class__ + if cls is text_type: + val = val.encode('utf-8') + elif cls is not binary_type: + val = str(val).encode('utf-8') + return _url_quote(val, safe=safe) def urlencode(query, doseq=True): """ @@ -72,4 +77,3 @@ def quote_plus(val, safe=''): elif cls is not binary_type: val = str(val).encode('utf-8') return _quote_plus(val, safe=safe) - diff --git a/pyramid/tests/test_config/test_views.py b/pyramid/tests/test_config/test_views.py index c722c9166..57bb5e9d0 100644 --- a/pyramid/tests/test_config/test_views.py +++ b/pyramid/tests/test_config/test_views.py @@ -3839,7 +3839,7 @@ class TestStaticURLInfo(unittest.TestCase): result = inst.generate('package:path/abc def', request, a=1, _anchor=uc) self.assertEqual(result, - 'http://example.com/abc%20def#La+Pe%C3%B1a') + 'http://example.com/abc%20def#La%20Pe%C3%B1a') def test_add_already_exists(self): inst = self._makeOne() diff --git a/pyramid/tests/test_url.py b/pyramid/tests/test_url.py index 6a8624a9f..22ccd1d0e 100644 --- a/pyramid/tests/test_url.py +++ b/pyramid/tests/test_url.py @@ -160,7 +160,7 @@ class TestURLMethodsMixin(unittest.TestCase): uc = text_(b'La Pe\xc3\xb1a', 'utf-8') result = request.resource_url(context, anchor=uc) self.assertEqual(result, - 'http://example.com:5432/context/#La+Pe%C3%B1a') + 'http://example.com:5432/context/#La%20Pe%C3%B1a') def test_resource_url_anchor_is_urlencoded_safe(self): request = self._makeOne() @@ -168,7 +168,7 @@ class TestURLMethodsMixin(unittest.TestCase): context = DummyContext() result = request.resource_url(context, anchor=' /#?&+') self.assertEqual(result, - 'http://example.com:5432/context/#+/%23?&+') + 'http://example.com:5432/context/#%20/%23?&+') def test_resource_url_no_IResourceURL_registered(self): # falls back to ResourceURL @@ -452,7 +452,7 @@ class TestURLMethodsMixin(unittest.TestCase): result = request.route_url('flub', _anchor=b"La Pe\xc3\xb1a") self.assertEqual(result, - 'http://example.com:5432/1/2/3#La+Pe%C3%B1a') + 'http://example.com:5432/1/2/3#La%20Pe%C3%B1a') def test_route_url_with_anchor_unicode(self): from pyramid.interfaces import IRoutesMapper @@ -463,7 +463,7 @@ class TestURLMethodsMixin(unittest.TestCase): result = request.route_url('flub', _anchor=anchor) self.assertEqual(result, - 'http://example.com:5432/1/2/3#La+Pe%C3%B1a') + 'http://example.com:5432/1/2/3#La%20Pe%C3%B1a') def test_route_url_with_query(self): from pyramid.interfaces import IRoutesMapper diff --git a/pyramid/url.py b/pyramid/url.py index e760bb356..629c1531a 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -14,10 +14,9 @@ from pyramid.interfaces import ( from pyramid.compat import ( bytes_, string_types, - url_quote, ) from pyramid.encode import ( - quote_plus, + url_quote, urlencode, ) from pyramid.path import caller_package @@ -49,13 +48,13 @@ def parse_url_overrides(kw): if '_query' in kw: query = kw.pop('_query') if isinstance(query, string_types): - qs = '?' + quote_plus(query, safe=QUERY_SAFE) + qs = '?' + url_quote(query, QUERY_SAFE) elif query: qs = '?' + urlencode(query, doseq=True) if '_anchor' in kw: anchor = kw.pop('_anchor') - anchor = quote_plus(anchor, safe=ANCHOR_SAFE) + anchor = url_quote(anchor, ANCHOR_SAFE) anchor = '#' + anchor if '_app_url' in kw: @@ -598,13 +597,13 @@ class URLMethodsMixin(object): if 'query' in kw: query = kw['query'] if isinstance(query, string_types): - qs = '?' + quote_plus(query, safe=QUERY_SAFE) + qs = '?' + url_quote(query, QUERY_SAFE) elif query: qs = '?' + urlencode(query, doseq=True) if 'anchor' in kw: anchor = kw['anchor'] - anchor = quote_plus(anchor, safe=ANCHOR_SAFE) + anchor = url_quote(anchor, ANCHOR_SAFE) anchor = '#' + anchor if elements: -- cgit v1.2.3 From a3654e4866303695478fb7bd01dcdd602a717b4e Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Tue, 12 Nov 2013 01:36:10 -0600 Subject: update docs --- CHANGES.txt | 11 +++++++++++ pyramid/url.py | 45 ++++++++++++++++++++++++++++++++++----------- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 895dc572f..bf1c1ea01 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -29,6 +29,17 @@ Features timeouts, and conformance with the ``ISession`` API. See https://github.com/Pylons/pyramid/pull/1142 +- Allow ``pyramid.request.Request.route_url`` and + ``pyramid.request.Request.resource_url`` to accept strings for their + query string to enable alternative encodings. Also the anchor argument + will now be escaped to ensure minimal conformance. + See https://github.com/Pylons/pyramid/pull/1183 + +- Allow sending of ``_query`` and ``_anchor`` options to + ``pyramid.request.Request.static_url`` when an external URL is being + generated. + See https://github.com/Pylons/pyramid/pull/1183 + Bug Fixes --------- diff --git a/pyramid/url.py b/pyramid/url.py index 629c1531a..14f4add35 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -171,11 +171,15 @@ class URLMethodsMixin(object): query string will be returned in the URL. If it is present, it will be used to compose a query string that will be tacked on to the end of the URL, replacing any request query string. - The value of ``_query`` must be a sequence of two-tuples *or* + The value of ``_query`` may be a sequence of two-tuples *or* a data structure with an ``.items()`` method that returns a sequence of two-tuples (presumably a dictionary). This data structure will be turned into a query string per the - documentation of :func:`pyramid.encode.urlencode` function. + documentation of :func:`pyramid.url.urlencode` function. + Alternative encodings may be used by passing a string for ``_query`` + in which case it will be quoted as per :rfc:`3986#section-3.4` but + no other assumptions will be made about the data format. For example, + spaces will be escaped as ``%20`` instead of ``+``. After the query data is turned into a query string, a leading ``?`` is prepended, and the resulting string is appended to the generated URL. @@ -189,8 +193,13 @@ class URLMethodsMixin(object): as values, and a k=v pair will be placed into the query string for each value. + .. versionchanged:: 1.5 + Allow the ``_query`` option to be a string to enable alternative + encodings. + If a keyword argument ``_anchor`` is present, its string - representation will be used as a named anchor in the generated URL + representation will be quoted per :rfc:`3986#section-3.5` and used as + a named anchor in the generated URL (e.g. if ``_anchor`` is passed as ``foo`` and the route URL is ``http://example.com/route/url``, the resulting generated URL will be ``http://example.com/route/url#foo``). @@ -199,8 +208,11 @@ class URLMethodsMixin(object): If ``_anchor`` is passed as a string, it should be UTF-8 encoded. If ``_anchor`` is passed as a Unicode object, it will be converted to - UTF-8 before being appended to the URL. The anchor value is not - quoted in any way before being appended to the generated URL. + UTF-8 before being appended to the URL. + + .. versionchanged:: 1.5 + The ``_anchor`` option will be escaped instead of using + its raw string representation. If both ``_anchor`` and ``_query`` are specified, the anchor element will always follow the query element, @@ -351,13 +363,17 @@ class URLMethodsMixin(object): If a keyword argument ``query`` is present, it will be used to compose a query string that will be tacked on to the end of the URL. - The value of ``query`` must be a sequence of two-tuples *or* a data + The value of ``query`` may be a sequence of two-tuples *or* a data structure with an ``.items()`` method that returns a sequence of two-tuples (presumably a dictionary). This data structure will be turned into a query string per the documentation of - ``pyramid.url.urlencode`` function. After the query data is turned - into a query string, a leading ``?`` is prepended, and the resulting - string is appended to the generated URL. + :func:``pyramid.url.urlencode`` function. + Alternative encodings may be used by passing a string for ``query`` + in which case it will be quoted as per :rfc:`3986#section-3.4` but + no other assumptions will be made about the data format. For example, + spaces will be escaped as ``%20`` instead of ``+``. + After the query data is turned into a query string, a leading ``?`` is + prepended, and the resulting string is appended to the generated URL. .. note:: @@ -368,6 +384,10 @@ class URLMethodsMixin(object): as values, and a k=v pair will be placed into the query string for each value. + .. versionchanged:: 1.5 + Allow the ``query`` option to be a string to enable alternative + encodings. + If a keyword argument ``anchor`` is present, its string representation will be used as a named anchor in the generated URL (e.g. if ``anchor`` is passed as ``foo`` and the resource URL is @@ -378,8 +398,11 @@ class URLMethodsMixin(object): If ``anchor`` is passed as a string, it should be UTF-8 encoded. If ``anchor`` is passed as a Unicode object, it will be converted to - UTF-8 before being appended to the URL. The anchor value is not - quoted in any way before being appended to the generated URL. + UTF-8 before being appended to the URL. + + .. versionchanged:: 1.5 + The ``anchor`` option will be escaped instead of using + its raw string representation. If both ``anchor`` and ``query`` are specified, the anchor element will always follow the query element, -- cgit v1.2.3 From 71554c794c79aae40df2bfc660c54194c803bbb8 Mon Sep 17 00:00:00 2001 From: Marc Abramowitz Date: Wed, 13 Nov 2013 17:10:51 -0800 Subject: .travis.yml: Add 3.3 --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 9d4324ff8..bc82c8faf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: - 2.7 - pypy - 3.2 + - 3.3 script: python setup.py test -q -- cgit v1.2.3 From b745355eadb0c0a8d7e0e788da733aad08b0ed9d Mon Sep 17 00:00:00 2001 From: Michael Merickel Date: Fri, 15 Nov 2013 12:02:01 -0600 Subject: remove deprecation warning with new ipython 1.1 embedded terminal --- pyramid/scripts/pshell.py | 29 +++++++++++--- pyramid/tests/test_scripts/test_pshell.py | 64 +++++++++++++++++++++++++------ 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/pyramid/scripts/pshell.py b/pyramid/scripts/pshell.py index dd09bf457..12b078677 100644 --- a/pyramid/scripts/pshell.py +++ b/pyramid/scripts/pshell.py @@ -153,16 +153,12 @@ class PShellCommand(object): shell = None user_shell = self.options.python_shell.lower() if not user_shell: - shell = self.make_ipython_v0_11_shell() - if shell is None: - shell = self.make_ipython_v0_10_shell() + shell = self.make_ipython_shell() if shell is None: shell = self.make_bpython_shell() elif user_shell == 'ipython': - shell = self.make_ipython_v0_11_shell() - if shell is None: - shell = self.make_ipython_v0_10_shell() + shell = self.make_ipython_shell() elif user_shell == 'bpython': shell = self.make_bpython_shell() @@ -191,6 +187,27 @@ class PShellCommand(object): BPShell(locals_=env, banner=help + '\n') return shell + def make_ipython_shell(self): + shell = self.make_ipython_v1_1_shell() + if shell is None: + shell = self.make_ipython_v0_11_shell() + if shell is None: + shell = self.make_ipython_v0_10_shell() + return shell + + def make_ipython_v1_1_shell(self, IPShellFactory=None): + if IPShellFactory is None: # pragma: no cover + try: + from IPython.terminal.embed import ( + InteractiveShellEmbed) + IPShellFactory = InteractiveShellEmbed + except ImportError: + return None + def shell(env, help): + IPShell = IPShellFactory(banner2=help + '\n', user_ns=env) + IPShell() + return shell + def make_ipython_v0_11_shell(self, IPShellFactory=None): if IPShellFactory is None: # pragma: no cover try: diff --git a/pyramid/tests/test_scripts/test_pshell.py b/pyramid/tests/test_scripts/test_pshell.py index 8f9f3abfb..7cb130c41 100644 --- a/pyramid/tests/test_scripts/test_pshell.py +++ b/pyramid/tests/test_scripts/test_pshell.py @@ -42,6 +42,15 @@ class TestPShellCommand(unittest.TestCase): self.assertEqual(bpython.locals_, {'foo': 'bar'}) self.assertTrue('a help message' in bpython.banner) + def test_make_ipython_v1_1_shell(self): + command = self._makeOne() + ipshell_factory = dummy.DummyIPShellFactory() + shell = command.make_ipython_v1_1_shell(ipshell_factory) + shell({'foo': 'bar'}, 'a help message') + self.assertEqual(ipshell_factory.kw['user_ns'], {'foo': 'bar'}) + self.assertTrue('a help message' in ipshell_factory.kw['banner2']) + self.assertTrue(ipshell_factory.shell.called) + def test_make_ipython_v0_11_shell(self): command = self._makeOne() ipshell_factory = dummy.DummyIPShellFactory() @@ -64,8 +73,7 @@ class TestPShellCommand(unittest.TestCase): def test_command_loads_default_shell(self): command = self._makeOne() shell = dummy.DummyShell() - command.make_ipython_v0_11_shell = lambda: None - command.make_ipython_v0_10_shell = lambda: None + command.make_ipython_shell = lambda: None command.make_bpython_shell = lambda: None command.make_default_shell = lambda: shell command.run() @@ -86,8 +94,7 @@ class TestPShellCommand(unittest.TestCase): command = self._makeOne() shell = dummy.DummyShell() bad_shell = dummy.DummyShell() - command.make_ipython_v0_11_shell = lambda: bad_shell - command.make_ipython_v0_10_shell = lambda: bad_shell + command.make_ipython_shell = lambda: bad_shell command.make_bpython_shell = lambda: bad_shell command.make_default_shell = lambda: shell command.options.python_shell = 'unknow_python_shell' @@ -106,9 +113,33 @@ class TestPShellCommand(unittest.TestCase): self.assertTrue(self.bootstrap.closer.called) self.assertTrue(shell.help) + def test_command_loads_ipython_v1_1(self): + command = self._makeOne() + shell = dummy.DummyShell() + command.make_ipython_v1_1_shell = lambda: shell + command.make_ipython_v0_11_shell = lambda: None + command.make_ipython_v0_10_shell = lambda: None + command.make_bpython_shell = lambda: None + command.make_default_shell = lambda: None + command.options.python_shell = 'ipython' + command.run() + self.assertTrue(self.config_factory.parser) + self.assertEqual(self.config_factory.parser.filename, + '/foo/bar/myapp.ini') + self.assertEqual(self.bootstrap.a[0], '/foo/bar/myapp.ini#myapp') + self.assertEqual(shell.env, { + 'app':self.bootstrap.app, 'root':self.bootstrap.root, + 'registry':self.bootstrap.registry, + 'request':self.bootstrap.request, + 'root_factory':self.bootstrap.root_factory, + }) + self.assertTrue(self.bootstrap.closer.called) + self.assertTrue(shell.help) + def test_command_loads_ipython_v0_11(self): command = self._makeOne() shell = dummy.DummyShell() + command.make_ipython_v1_1_shell = lambda: None command.make_ipython_v0_11_shell = lambda: shell command.make_ipython_v0_10_shell = lambda: None command.make_bpython_shell = lambda: None @@ -131,6 +162,7 @@ class TestPShellCommand(unittest.TestCase): def test_command_loads_ipython_v0_10(self): command = self._makeOne() shell = dummy.DummyShell() + command.make_ipython_v1_1_shell = lambda: None command.make_ipython_v0_11_shell = lambda: None command.make_ipython_v0_10_shell = lambda: shell command.make_bpython_shell = lambda: None @@ -153,8 +185,7 @@ class TestPShellCommand(unittest.TestCase): def test_command_loads_bpython_shell(self): command = self._makeOne() shell = dummy.DummyBPythonShell() - command.make_ipython_v0_11_shell = lambda: None - command.make_ipython_v0_10_shell = lambda: None + command.make_ipython_shell = lambda: None command.make_bpython_shell = lambda: shell command.options.python_shell = 'bpython' command.run() @@ -173,25 +204,34 @@ class TestPShellCommand(unittest.TestCase): def test_shell_ipython_ordering(self): command = self._makeOne() + shell1_1 = dummy.DummyShell() shell0_11 = dummy.DummyShell() shell0_10 = dummy.DummyShell() + command.make_ipython_v1_1_shell = lambda: shell1_1 + shell = command.make_shell() + self.assertEqual(shell, shell1_1) + + command.make_ipython_v1_1_shell = lambda: None command.make_ipython_v0_11_shell = lambda: shell0_11 - command.make_ipython_v0_10_shell = lambda: shell0_10 - command.make_bpython_shell = lambda: None shell = command.make_shell() self.assertEqual(shell, shell0_11) + command.make_ipython_v0_11_shell = lambda: None + command.make_ipython_v0_10_shell = lambda: shell0_10 + shell = command.make_shell() + self.assertEqual(shell, shell0_10) + command.options.python_shell = 'ipython' + command.make_ipython_v1_1_shell = lambda: shell1_1 shell = command.make_shell() - self.assertEqual(shell, shell0_11) + self.assertEqual(shell, shell1_1) def test_shell_ordering(self): command = self._makeOne() ipshell = dummy.DummyShell() bpshell = dummy.DummyShell() dshell = dummy.DummyShell() - command.make_ipython_v0_11_shell = lambda: None - command.make_ipython_v0_10_shell = lambda: None + command.make_ipython_shell = lambda: None command.make_bpython_shell = lambda: None command.make_default_shell = lambda: dshell @@ -206,7 +246,7 @@ class TestPShellCommand(unittest.TestCase): shell = command.make_shell() self.assertEqual(shell, dshell) - command.make_ipython_v0_11_shell = lambda: ipshell + command.make_ipython_shell = lambda: ipshell command.make_bpython_shell = lambda: bpshell command.options.python_shell = 'ipython' shell = command.make_shell() -- cgit v1.2.3 From c3aae1ffaa2bf12dd2e6f4d8bb77b17a56fd4849 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Thu, 14 Feb 2013 12:00:49 +0200 Subject: Added a clarifying comment on i18n _LOCALE_ value --- pyramid/i18n.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyramid/i18n.py b/pyramid/i18n.py index cdedbc877..6ffd93e8f 100644 --- a/pyramid/i18n.py +++ b/pyramid/i18n.py @@ -107,7 +107,8 @@ def default_locale_negotiator(request): - First, the negotiator looks for the ``_LOCALE_`` attribute of the request object (possibly set by a view or a listener for an - :term:`event`). + :term:`event`). If the attribute exists and it is not ``None``, + its value will be used. - Then it looks for the ``request.params['_LOCALE_']`` value. -- cgit v1.2.3 From 04e6bf670e3968864fd858e3d3fa310d601a2420 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sat, 16 Nov 2013 00:31:48 +0200 Subject: Enhanced the narrative documentation for tweens. --- docs/narr/hooks.rst | 87 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/docs/narr/hooks.rst b/docs/narr/hooks.rst index 0c450fad7..b26a46272 100644 --- a/docs/narr/hooks.rst +++ b/docs/narr/hooks.rst @@ -963,8 +963,8 @@ For full details, please read the `Venusian documentation .. _registering_tweens: -Registering "Tweens" --------------------- +Registering Tweens +------------------ .. versionadded:: 1.2 Tweens @@ -976,23 +976,76 @@ feature that may be used by Pyramid framework extensions, to provide, for example, Pyramid-specific view timing support bookkeeping code that examines exceptions before they are returned to the upstream WSGI application. Tweens behave a bit like :term:`WSGI` :term:`middleware` but they have the benefit of -running in a context in which they have access to the Pyramid -:term:`application registry` as well as the Pyramid rendering machinery. +running in a context in which they have access to the Pyramid :term:`request`, +:term:`response` and :term:`application registry` as well as the Pyramid +rendering machinery. -Creating a Tween Factory -~~~~~~~~~~~~~~~~~~~~~~~~ +Creating a Tween +~~~~~~~~~~~~~~~~ -To make use of tweens, you must construct a "tween factory". A tween factory +To create a tween, you must write a "tween factory". A tween factory must be a globally importable callable which accepts two arguments: ``handler`` and ``registry``. ``handler`` will be the either the main Pyramid request handling function or another tween. ``registry`` will be the Pyramid :term:`application registry` represented by this Configurator. A -tween factory must return a tween when it is called. +tween factory must return the tween (a callable object) when it is called. -A tween is a callable which accepts a :term:`request` object and returns -a :term:`response` object. +A tween is called with a single argument, ``request``, which is the +:term:`request` created by Pyramid's router when it receives a WSGI request. +A tween should return a :term:`response`, usually the one generated by the +downstream Pyramid application. -Here's an example of a tween factory: +You can write the tween factory as a simple closure-returning function: + +.. code-block:: python + :linenos: + + def simple_tween_factory(handler, registry): + # one-time configuration code goes here + + def simple_tween(request): + # code to be executed for each request before + # the actual application code goes here + + response = handler(request) + + # code to be executed for each request after + # the actual application code goes here + + return response + + return handler + +Alternatively, the tween factory can be a class with the ``__call__`` magic method: + +.. code-block:: python + :linenos: + + class simple_tween_factory(object): + def __init__(handler, registry): + self.handler = handler + self.registry = registry + + # one-time configuration code goes here + + def __call__(self, request): + # code to be executed for each request before + # the actual application code goes here + + response = self.handler(request) + + # code to be executed for each request after + # the actual application code goes here + + return response + +The closure style performs slightly better and enables you to conditionally +omit the tween from the request processing pipeline (see the following timing +tween example), whereas the class style makes it easier to have shared mutable +state, and it allows subclassing. + +Here's a complete example of a tween that logs the time spent processing each +request: .. code-block:: python :linenos: @@ -1022,12 +1075,6 @@ Here's an example of a tween factory: # handler return handler -If you remember, a tween is an object which accepts a :term:`request` object -and which returns a :term:`response` argument. The ``request`` argument to a -tween will be the request created by Pyramid's router when it receives a WSGI -request. The response object will be generated by the downstream Pyramid -application and it should be returned by the tween. - In the above example, the tween factory defines a ``timing_tween`` tween and returns it if ``asbool(registry.settings.get('do_timing'))`` is true. It otherwise simply returns the handler it was given. The ``registry.settings`` @@ -1132,8 +1179,10 @@ Allowable values for ``under`` or ``over`` (or both) are: fallbacks if the desired tween is not included, as well as compatibility with multiple other tweens. -Effectively, ``under`` means "closer to the main Pyramid application than", -``over`` means "closer to the request ingress than". +Effectively, ``over`` means "closer to the request ingress than" and +``under`` means "closer to the main Pyramid application than". +You can think of an onion with outer layers over the inner layers, +the application being under all the layers at the center. For example, the following call to :meth:`~pyramid.config.Configurator.add_tween` will attempt to place the -- cgit v1.2.3 From 66944f419f0a287d89c502a1140358b2c5f81ff5 Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sat, 16 Nov 2013 00:44:31 +0200 Subject: Added Antti Haapala to CONTRIBUTORS.txt --- CONTRIBUTORS.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 6dba1076e..63528e662 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -226,3 +226,5 @@ Contributors - Karl O. Pinc, 2013/09/27 - Matthew Russell, 2013/10/14 + +- Antti Haapala, 2013/11/15 -- cgit v1.2.3 From a0ef135f2ad6e4425eec7ffcc0839b605347c346 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Sat, 16 Nov 2013 05:06:38 -0500 Subject: 80 chars --- docs/narr/hooks.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/narr/hooks.rst b/docs/narr/hooks.rst index b26a46272..84dd2143c 100644 --- a/docs/narr/hooks.rst +++ b/docs/narr/hooks.rst @@ -1016,7 +1016,8 @@ You can write the tween factory as a simple closure-returning function: return handler -Alternatively, the tween factory can be a class with the ``__call__`` magic method: +Alternatively, the tween factory can be a class with the ``__call__`` magic +method: .. code-block:: python :linenos: -- cgit v1.2.3 From 392a6c7df93b67d6889680133fda0f744970d61f Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 17 Nov 2013 00:11:37 +0200 Subject: Removed extra indentation from some examples (:linenos: should be indented with the same indentation as the rest of the code block) --- docs/designdefense.rst | 2 +- docs/narr/events.rst | 6 +++--- docs/narr/extending.rst | 2 +- docs/narr/hooks.rst | 20 ++++++++++---------- docs/narr/hybrid.rst | 2 +- docs/narr/i18n.rst | 8 ++++---- docs/narr/introduction.rst | 6 +++--- docs/narr/introspector.rst | 4 ++-- docs/narr/logging.rst | 2 +- docs/narr/resources.rst | 2 +- docs/narr/scaffolding.rst | 2 +- docs/narr/upgrading.rst | 4 ++-- docs/narr/urldispatch.rst | 10 +++++----- docs/narr/vhosting.rst | 4 ++-- docs/narr/views.rst | 2 +- docs/tutorials/wiki/authorization.rst | 4 ++-- 16 files changed, 40 insertions(+), 40 deletions(-) diff --git a/docs/designdefense.rst b/docs/designdefense.rst index bbce3e29c..2f3c14881 100644 --- a/docs/designdefense.rst +++ b/docs/designdefense.rst @@ -1078,7 +1078,7 @@ The contents of ``app2.py``: The contents of ``config.py``: .. code-block:: python - :linenos: + :linenos: L = [] diff --git a/docs/narr/events.rst b/docs/narr/events.rst index 2accb3dbe..50484761d 100644 --- a/docs/narr/events.rst +++ b/docs/narr/events.rst @@ -172,7 +172,7 @@ track of the information that subscribers will need. Here are some example custom event classes: .. code-block:: python - :linenos: + :linenos: class DocCreated(object): def __init__(self, doc, request): @@ -196,7 +196,7 @@ also use custom events with :ref:`subscriber predicates event with a decorator: .. code-block:: python - :linenos: + :linenos: from pyramid.events import subscriber from .events import DocCreated @@ -215,7 +215,7 @@ To fire your custom events use the accessed as ``request.registry.notify``. For example: .. code-block:: python - :linenos: + :linenos: from .events import DocCreated diff --git a/docs/narr/extending.rst b/docs/narr/extending.rst index a60a49fea..8462a9da7 100644 --- a/docs/narr/extending.rst +++ b/docs/narr/extending.rst @@ -234,7 +234,7 @@ For example, if the original application has the following ``configure_views`` configuration method: .. code-block:: python - :linenos: + :linenos: def configure_views(config): config.add_view('theoriginalapp.views.theview', name='theview') diff --git a/docs/narr/hooks.rst b/docs/narr/hooks.rst index 84dd2143c..14009a094 100644 --- a/docs/narr/hooks.rst +++ b/docs/narr/hooks.rst @@ -363,7 +363,7 @@ and modify the set of :term:`renderer globals` before they are passed to a that can be used for this purpose. For example: .. code-block:: python - :linenos: + :linenos: from pyramid.events import subscriber from pyramid.events import BeforeRender @@ -998,7 +998,7 @@ downstream Pyramid application. You can write the tween factory as a simple closure-returning function: .. code-block:: python - :linenos: + :linenos: def simple_tween_factory(handler, registry): # one-time configuration code goes here @@ -1020,7 +1020,7 @@ Alternatively, the tween factory can be a class with the ``__call__`` magic method: .. code-block:: python - :linenos: + :linenos: class simple_tween_factory(object): def __init__(handler, registry): @@ -1049,7 +1049,7 @@ Here's a complete example of a tween that logs the time spent processing each request: .. code-block:: python - :linenos: + :linenos: # in a module named myapp.tweens @@ -1101,7 +1101,7 @@ Here's an example of registering a tween factory as an "implicit" tween in a Pyramid application: .. code-block:: python - :linenos: + :linenos: from pyramid.config import Configurator config = Configurator() @@ -1135,7 +1135,7 @@ chain (the tween generated by the very last tween factory added) as its request handler function. For example: .. code-block:: python - :linenos: + :linenos: from pyramid.config import Configurator @@ -1379,7 +1379,7 @@ route predicate factory is most often a class with a constructor method. For example: .. code-block:: python - :linenos: + :linenos: class ContentTypePredicate(object): def __init__(self, val, config): @@ -1442,7 +1442,7 @@ with a subscriber that subscribes to the :class:`pyramid.events.NewRequest` event type. .. code-block:: python - :linenos: + :linenos: class RequestPathStartsWith(object): def __init__(self, val, config): @@ -1471,7 +1471,7 @@ previously registered ``request_path_startswith`` predicate in a call to :meth:`~pyramid.config.Configurator.add_subscriber`: .. code-block:: python - :linenos: + :linenos: # define a subscriber in your code @@ -1487,7 +1487,7 @@ Here's the same subscriber/predicate/event-type combination used via :class:`~pyramid.events.subscriber`. .. code-block:: python - :linenos: + :linenos: from pyramid.events import subscriber diff --git a/docs/narr/hybrid.rst b/docs/narr/hybrid.rst index a29ccb2ac..4a3258d35 100644 --- a/docs/narr/hybrid.rst +++ b/docs/narr/hybrid.rst @@ -63,7 +63,7 @@ An application that uses only traversal will have view configuration declarations that look like this: .. code-block:: python - :linenos: + :linenos: # config is an instance of pyramid.config.Configurator diff --git a/docs/narr/i18n.rst b/docs/narr/i18n.rst index b62c16ff0..c9b782c08 100644 --- a/docs/narr/i18n.rst +++ b/docs/narr/i18n.rst @@ -309,7 +309,7 @@ In particular, add the ``Babel`` and ``lingua`` distributions to the application's ``setup.py`` file: .. code-block:: python - :linenos: + :linenos: setup(name="mypackage", # ... @@ -370,7 +370,7 @@ file of a ``pcreate`` -generated :app:`Pyramid` application has stanzas in it that look something like the following: .. code-block:: ini - :linenos: + :linenos: [compile_catalog] directory = myproject/locale @@ -398,7 +398,7 @@ that you'd like the domain of your translations to be ``mydomain`` instead, change the ``setup.cfg`` file stanzas to look like so: .. code-block:: ini - :linenos: + :linenos: [compile_catalog] directory = myproject/locale @@ -1041,7 +1041,7 @@ if no locale can be determined. Here's an implementation of a simple locale negotiator: .. code-block:: python - :linenos: + :linenos: def my_locale_negotiator(request): locale_name = request.params.get('my_locale') diff --git a/docs/narr/introduction.rst b/docs/narr/introduction.rst index a9c5fdfbd..8acbab3a0 100644 --- a/docs/narr/introduction.rst +++ b/docs/narr/introduction.rst @@ -336,7 +336,7 @@ For example, instead of returning a ``Response`` object from a ``render_to_response`` call: .. code-block:: python - :linenos: + :linenos: from pyramid.renderers import render_to_response @@ -347,7 +347,7 @@ For example, instead of returning a ``Response`` object from a You can return a Python dictionary: .. code-block:: python - :linenos: + :linenos: from pyramid.view import view_config @@ -827,7 +827,7 @@ Here's an example of using Pyramid's introspector from within a view callable: .. code-block:: python - :linenos: + :linenos: from pyramid.view import view_config from pyramid.response import Response diff --git a/docs/narr/introspector.rst b/docs/narr/introspector.rst index 3c0a6744f..a7bde4cf7 100644 --- a/docs/narr/introspector.rst +++ b/docs/narr/introspector.rst @@ -24,7 +24,7 @@ Here's an example of using Pyramid's introspector from within a view callable: .. code-block:: python - :linenos: + :linenos: from pyramid.view import view_config from pyramid.response import Response @@ -100,7 +100,7 @@ its ``__getitem__``, ``get``, ``keys``, ``values``, or ``items`` methods. For example: .. code-block:: python - :linenos: + :linenos: route_intr = introspector.get('routes', 'edit_user') pattern = route_intr['pattern'] diff --git a/docs/narr/logging.rst b/docs/narr/logging.rst index b3bfb8a1e..75428d513 100644 --- a/docs/narr/logging.rst +++ b/docs/narr/logging.rst @@ -179,7 +179,7 @@ file, simply create a logger object using the ``__name__`` builtin and call methods on it. .. code-block:: python - :linenos: + :linenos: import logging log = logging.getLogger(__name__) diff --git a/docs/narr/resources.rst b/docs/narr/resources.rst index 34d75f2cc..f3ff1dc4c 100644 --- a/docs/narr/resources.rst +++ b/docs/narr/resources.rst @@ -83,7 +83,7 @@ works against resource instances. Here's a sample resource tree, represented by a variable named ``root``: .. code-block:: python - :linenos: + :linenos: class Resource(dict): pass diff --git a/docs/narr/scaffolding.rst b/docs/narr/scaffolding.rst index 534b2caf4..9952b6818 100644 --- a/docs/narr/scaffolding.rst +++ b/docs/narr/scaffolding.rst @@ -112,7 +112,7 @@ want to have extension scaffolds that can work across Pyramid 1.0.X, 1.1.X, defining your scaffold template: .. code-block:: python - :linenos: + :linenos: try: # pyramid 1.0.X # "pyramid.paster.paste_script..." doesn't exist past 1.0.X diff --git a/docs/narr/upgrading.rst b/docs/narr/upgrading.rst index 64343ca3e..eb3194a65 100644 --- a/docs/narr/upgrading.rst +++ b/docs/narr/upgrading.rst @@ -137,7 +137,7 @@ In the above case, it's line #3 in the ``myproj.views`` module (``from pyramid.view import static``) that is causing the problem: .. code-block:: python - :linenos: + :linenos: from pyramid.view import view_config @@ -148,7 +148,7 @@ The deprecation warning tells me how to fix it, so I can change the code to do things the newer way: .. code-block:: python - :linenos: + :linenos: from pyramid.view import view_config diff --git a/docs/narr/urldispatch.rst b/docs/narr/urldispatch.rst index 61849c3c0..96ee5758e 100644 --- a/docs/narr/urldispatch.rst +++ b/docs/narr/urldispatch.rst @@ -492,7 +492,7 @@ The simplest route declaration which configures a route match to *directly* result in a particular view callable being invoked: .. code-block:: python - :linenos: + :linenos: config.add_route('idea', 'site/{id}') config.add_view('mypackage.views.site_view', route_name='idea') @@ -901,7 +901,7 @@ Details of the route matching decision for a particular request to the which you started the application from. For example: .. code-block:: text - :linenos: + :linenos: $ PYRAMID_DEBUG_ROUTEMATCH=true $VENV/bin/pserve development.ini Starting server in PID 13586. @@ -1060,7 +1060,7 @@ A custom route predicate may also *modify* the ``match`` dictionary. For instance, a predicate might do some type conversion of values: .. code-block:: python - :linenos: + :linenos: def integers(*segment_names): def predicate(info, request): @@ -1086,7 +1086,7 @@ To avoid the try/except uncertainty, the route pattern can contain regular expressions specifying requirements for that marker. For instance: .. code-block:: python - :linenos: + :linenos: def integers(*segment_names): def predicate(info, request): @@ -1128,7 +1128,7 @@ name. The ``pattern`` attribute is the route pattern. An example of using the route in a set of route predicates: .. code-block:: python - :linenos: + :linenos: def twenty_ten(info, request): if info['route'].name in ('ymd', 'ym', 'y'): diff --git a/docs/narr/vhosting.rst b/docs/narr/vhosting.rst index d37518052..53f6888b3 100644 --- a/docs/narr/vhosting.rst +++ b/docs/narr/vhosting.rst @@ -109,7 +109,7 @@ An example of an Apache ``mod_proxy`` configuration that will host the is below: .. code-block:: apache - :linenos: + :linenos: NameVirtualHost *:80 @@ -130,7 +130,7 @@ For a :app:`Pyramid` application running under :term:`mod_wsgi`, the same can be achieved using ``SetEnv``: .. code-block:: apache - :linenos: + :linenos: SetEnv HTTP_X_VHM_ROOT /cms diff --git a/docs/narr/views.rst b/docs/narr/views.rst index b2dd549ce..a746eb043 100644 --- a/docs/narr/views.rst +++ b/docs/narr/views.rst @@ -536,7 +536,7 @@ The following types work as view callables in this style: e.g.: .. code-block:: python - :linenos: + :linenos: from pyramid.response import Response diff --git a/docs/tutorials/wiki/authorization.rst b/docs/tutorials/wiki/authorization.rst index 62b1164e3..93cd0c18e 100644 --- a/docs/tutorials/wiki/authorization.rst +++ b/docs/tutorials/wiki/authorization.rst @@ -149,8 +149,8 @@ to the ``@view_config`` decorator for ``add_page()`` and ``edit_page()``, for example: .. code-block:: python - :linenos: - :emphasize-lines: 3 + :linenos: + :emphasize-lines: 3 @view_config(name='add_page', context='.models.Wiki', renderer='templates/edit.pt', -- cgit v1.2.3 From bd897bbcfd936d36ad9877a2f00792454767b7fb Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 17 Nov 2013 01:02:29 +0200 Subject: Fixed indentation issues --- docs/narr/scaffolding.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/narr/scaffolding.rst b/docs/narr/scaffolding.rst index 9952b6818..f924d0d62 100644 --- a/docs/narr/scaffolding.rst +++ b/docs/narr/scaffolding.rst @@ -39,9 +39,9 @@ named ``__init__.py`` with something like the following: from pyramid.scaffolds import PyramidTemplate - class CoolExtensionTemplate(PyramidTemplate): - _template_dir = 'coolextension_scaffold' - summary = 'My cool extension' + class CoolExtensionTemplate(PyramidTemplate): + _template_dir = 'coolextension_scaffold' + summary = 'My cool extension' Once this is done, within the ``scaffolds`` directory, create a template directory. Our example used a template directory named @@ -89,7 +89,7 @@ For example: [pyramid.scaffold] coolextension=coolextension.scaffolds:CoolExtensionTemplate """ - ) + ) Run your distribution's ``setup.py develop`` or ``setup.py install`` command. After that, you should be able to see your scaffolding template -- cgit v1.2.3 From fab44e1e402efbf37fc58875974e9ae42827446e Mon Sep 17 00:00:00 2001 From: Antti Haapala Date: Sun, 17 Nov 2013 19:08:18 +0200 Subject: Should return the simple_tween here, not the handler. --- docs/narr/hooks.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/narr/hooks.rst b/docs/narr/hooks.rst index 14009a094..f2542f1d7 100644 --- a/docs/narr/hooks.rst +++ b/docs/narr/hooks.rst @@ -1014,7 +1014,7 @@ You can write the tween factory as a simple closure-returning function: return response - return handler + return simple_tween Alternatively, the tween factory can be a class with the ``__call__`` magic method: -- cgit v1.2.3 From 34ea769fba7a890ac0cc69b86ccc2002de864b15 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 18 Nov 2013 07:32:30 -0500 Subject: remove lies --- CHANGES.txt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 9f780fe45..ad51ed174 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -119,12 +119,6 @@ Deprecations - The ``pyramid.security.has_permission`` API is now deprecated. Instead, use the newly-added ``has_permission`` method of the request object. -- The ``pyramid.security.forget`` API is now deprecated. Instead, use - the newly-added ``forget_userid`` method of the request object. - -- The ``pyramid.security.remember`` API is now deprecated. Instead, use - the newly-added ``remember_userid`` method of the request object. - - The ``pyramid.security.effective_principals`` API is now deprecated. Instead, use the newly-added ``effective_principals`` attribute of the request object. -- cgit v1.2.3 From 4caf2b0a553b09009299f23057820dd447ec5705 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 18 Nov 2013 07:37:10 -0500 Subject: update whatsnew in 1.5 --- docs/whatsnew-1.5.rst | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/whatsnew-1.5.rst b/docs/whatsnew-1.5.rst index 57f93cbff..23613896a 100644 --- a/docs/whatsnew-1.5.rst +++ b/docs/whatsnew-1.5.rst @@ -316,6 +316,48 @@ The feature additions in Pyramid 1.5 follow. - :func:`pyramid.path.package_name` no longer thows an exception when resolving the package name for namespace packages that have no ``__file__`` attribute. +- An authorization API has been added as a method of the request: + :meth:`pyramid.request.Request.has_permission`. It is a method-based + alternative to the :func:`pyramid.security.has_permission` API and works + exactly the same. The older API is now deprecated. + +- Property API attributes have been added to the request for easier access to + authentication data: :attr:`pyramid.request.Request.authenticated_userid`, + :attr:`pyramid.request.Request.unauthenticated_userid`, and + :attr:`pyramid.request.Request.effective_principals`. These are analogues, + respectively, of :func:`pyramid.security.authenticated_userid`, + :func:`pyramid.security.unauthenticated_userid`, and + :func:`pyramid.security.effective_principals`. They operate exactly the + same, except they are attributes of the request instead of functions + accepting a request. They are properties, so they cannot be assigned to. + The older function-based APIs are now deprecated. + +- Pyramid's console scripts (``pserve``, ``pviews``, etc) can now be run + directly, allowing custom arguments to be sent to the python interpreter + at runtime. For example:: + + python -3 -m pyramid.scripts.pserve development.ini + +- Added a specific subclass of :class:`pyramid.httpexceptions.HTTPBadRequest` + named :class:`pyramid.exceptions.BadCSRFToken` which will now be raised in + response to failures in the ``check_csrf_token`` view predicate. See + https://github.com/Pylons/pyramid/pull/1149 + +- Added a new ``SignedCookieSessionFactory`` which is very similar to the + ``UnencryptedCookieSessionFactoryConfig`` but with a clearer focus on + signing content. The custom serializer arguments to this function should + only focus on serializing, unlike its predecessor which required the + serializer to also perform signing. + See https://github.com/Pylons/pyramid/pull/1142 + +- Added a new ``BaseCookieSessionFactory`` which acts as a generic cookie + factory that can be used by framework implementors to create their own + session implementations. It provides a reusable API which focuses strictly + on providing a dictionary-like object that properly handles renewals, + timeouts, and conformance with the ``ISession`` API. + See https://github.com/Pylons/pyramid/pull/1142 + + Other Backwards Incompatibilities --------------------------------- @@ -404,6 +446,13 @@ Other Backwards Incompatibilities Pyramid narrative documentation instead of providing renderer globals values to the configurator. +- The key/values in the ``_query`` parameter of + :meth:`pyramid.request.Request.route_url` and the ``query`` parameter of + :meth:`pyramid.request.Request.resource_url` (and their variants), used to + encode a value of ``None`` as the string ``'None'``, leaving the resulting + query string to be ``a=b&key=None``. The value is now dropped in this + situation, leaving a query string of ``a=b&key=``. See + https://github.com/Pylons/pyramid/issues/1119 Deprecations ------------ @@ -417,12 +466,36 @@ Deprecations a deprecation warning when used. It had been docs-deprecated in 1.4 but did not issue a deprecation warning when used. +- :func:`pyramid.security.has_permission` is now deprecated in favor of using + :meth:`pyramid.request.Request.has_permission`. + +- The :func:`pyramid.security.authenticated_userid`, + :func:`pyramid.security.unauthenticated_userid`, and + :func:`pyramid.security.effective_principals` functions have been + deprecated. Use :attr:`pyramid.request.Request.authenticated_userid`, + :attr:`pyramid.request.Request.unauthenticated_userid` and + :attr:`pyramid.request.Request.effective_principals` instead. + +- Deprecate the ``pyramid.interfaces.ITemplateRenderer`` interface. It was + ill-defined and became unused when Mako and Chameleon template bindings were + split into their own packages. + +- The ``pyramid.session.UnencryptedCookieSessionFactoryConfig`` API has been + deprecated and is superseded by the + ``pyramid.session.SignedCookieSessionFactory``. Note that while the cookies + generated by the ``UnencryptedCookieSessionFactoryConfig`` + are compatible with cookies generated by old releases, cookies generated by + the SignedCookieSessionFactory are not. See + https://github.com/Pylons/pyramid/pull/1142 + Documentation Enhancements -------------------------- - A new documentation chapter named :ref:`quick_tour` was added. It describes starting out with Pyramid from a high level. +- Added a :ref:`quick_tutorial` to go with the Quick Tour + - Many other enhancements. -- cgit v1.2.3 From 7c81e0f5296455248b787c9dd755793dec99ba7c Mon Sep 17 00:00:00 2001 From: Ole Morten Halvorsen Date: Wed, 20 Nov 2013 20:53:54 +1100 Subject: Fixed typo - removed 'hair'. --- HACKING.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/HACKING.txt b/HACKING.txt index b32a8a957..3c83261c7 100644 --- a/HACKING.txt +++ b/HACKING.txt @@ -212,9 +212,9 @@ using to develop Pyramid): $ cd ~/hack-on-pyramid/pyramid/docs $ make clean html SPHINXBUILD=$VENV/bin/sphinx-build - The ``SPHINXBUILD=...`` hair is there in order to tell it to use the - virtualenv Python, which will have both Sphinx and Pyramid (for API - documentation generation) installed. + The ``SPHINXBUILD=...`` is there to tell it to use the virtualenv Python, + which will have both Sphinx and Pyramid (for API documentation + generation) installed. 4. Open the ``docs/_build/html/index.html`` file to see the resulting HTML rendering. -- cgit v1.2.3 From a767d8b0a54b4e93515c3faa4348bc4a34eed0cb Mon Sep 17 00:00:00 2001 From: Steve Piercy Date: Wed, 20 Nov 2013 02:43:52 -0800 Subject: improve grammar and remove slang --- HACKING.txt | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/HACKING.txt b/HACKING.txt index 3c83261c7..12f2d68e2 100644 --- a/HACKING.txt +++ b/HACKING.txt @@ -6,9 +6,9 @@ Here are some guidelines about hacking on Pyramid. Using a Development Checkout ---------------------------- -You'll have to create a development environment to hack on Pyramid, using a -Pyramid checkout. You can either do this by hand or, if you have ``tox`` -installed (it's on PyPI), you can (ab)use tox to get a working development +You'll have to create a development environment to hack on Pyramid, using a +Pyramid checkout. You can either do this by hand or, if you have ``tox`` +installed (it's on PyPI), you can (ab)use tox to get a working development environment. Each installation method is described below. By Hand @@ -25,15 +25,15 @@ By Hand $ cd ~/hack-on-pyramid $ virtualenv -ppython2.7 env - Note that very old versions of virtualenv (virtualenv versions below, say, + Note that very old versions of virtualenv (virtualenv versions below, say, 1.10 or thereabouts) require you to pass a ``--no-site-packages`` flag to get a completely isolated environment. - You can choose which Python version you want to use by passing a ``-p`` + You can choose which Python version you want to use by passing a ``-p`` flag to ``virtualenv``. For example, ``virtualenv -ppython2.7`` chooses the Python 2.7 interpreter to be installed. - From here on in within these instructions, the ``~/hack-on-pyramid/env`` + From here on in within these instructions, the ``~/hack-on-pyramid/env`` virtual environment you created above will be referred to as ``$VENV``. To use the instructions in the steps that follow literally, use the ``export VENV=~/hack-on-pyramid/env`` command. @@ -132,7 +132,7 @@ Coding Style - PEP8 compliance. Whitespace rules are relaxed: not necessary to put 2 newlines between classes. But 80-column lines, in particular, are - mandatory. See + mandatory. See http://docs.pylonsproject.org/en/latest/community/codestyle.html for more information. @@ -142,14 +142,14 @@ Coding Style Running Tests -------------- -- To run all tests for Pyramid on a single Python version, run ``nosetests`` +- To run all tests for Pyramid on a single Python version, run ``nosetests`` from your development virtualenv (See *Using a Development Checkout* above). - To run individual tests (i.e. during development) you can use a regular expression with the ``-t`` parameter courtesy of the `nose-selecttests - `_ plugin that's been - installed (along with nose itself) via ``python setup.py dev``. The - easiest usage is to simply provide the verbatim name of the test you're + `_ plugin that's been + installed (along with nose itself) via ``python setup.py dev``. The + easiest usage is to simply provide the verbatim name of the test you're working on. - To run the full set of Pyramid tests on all platforms, install ``tox`` @@ -191,8 +191,8 @@ or adds the feature. To build and review docs (where ``$VENV`` refers to the virtualenv you're using to develop Pyramid): -1. After following the steps above in "Using a Development Checkout", cause - Sphinx and all development requirements to be installed in your +1. After following the steps above in "Using a Development Checkout", cause + Sphinx and all development requirements to be installed in your virtualenv:: $ cd ~/hack-on-pyramid @@ -212,9 +212,9 @@ using to develop Pyramid): $ cd ~/hack-on-pyramid/pyramid/docs $ make clean html SPHINXBUILD=$VENV/bin/sphinx-build - The ``SPHINXBUILD=...`` is there to tell it to use the virtualenv Python, - which will have both Sphinx and Pyramid (for API documentation - generation) installed. + The ``SPHINXBUILD=...`` argument tells Sphinx to use the virtualenv Python, + which will have both Sphinx and Pyramid (for API documentation generation) + installed. 4. Open the ``docs/_build/html/index.html`` file to see the resulting HTML rendering. -- cgit v1.2.3 From 9eb79397b4e552bb76bef761593b25c071a616b2 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 27 Nov 2013 03:58:34 -0500 Subject: coverage --- pyramid/tests/test_encode.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyramid/tests/test_encode.py b/pyramid/tests/test_encode.py index 908249877..8fb766d88 100644 --- a/pyramid/tests/test_encode.py +++ b/pyramid/tests/test_encode.py @@ -72,3 +72,8 @@ class URLQuoteTests(unittest.TestCase): la = b'La/Pe\xc3\xb1a' result = self._callFUT(la, '/') self.assertEqual(result, 'La/Pe%C3%B1a') + + def test_it_with_nonstr_nonbinary(self): + la = None + result = self._callFUT(la, '/') + self.assertEqual(result, 'None') -- cgit v1.2.3 From 06aee8b0c35d5fdcd305ff6c7107d936bcdb7d32 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 27 Nov 2013 04:23:22 -0500 Subject: change the behavior of parse_url_overrides and resource_url to not quote a _query/query argument supplied as a string and document in changelog --- CHANGES.txt | 17 +++++++++----- pyramid/tests/test_url.py | 1 - pyramid/url.py | 57 +++++++++++++++++++++++------------------------ 3 files changed, 40 insertions(+), 35 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index d6f5ea792..40efecce1 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -48,17 +48,24 @@ Features timeouts, and conformance with the ``ISession`` API. See https://github.com/Pylons/pyramid/pull/1142 -- Allow ``pyramid.request.Request.route_url`` and - ``pyramid.request.Request.resource_url`` to accept strings for their - query string to enable alternative encodings. Also the anchor argument - will now be escaped to ensure minimal conformance. - See https://github.com/Pylons/pyramid/pull/1183 +- The anchor argument to ``pyramid.request.Request.route_url`` and + ``pyramid.request.Request.resource_url`` and their derivatives will now be + escaped to ensure minimal conformance. See + https://github.com/Pylons/pyramid/pull/1183 - Allow sending of ``_query`` and ``_anchor`` options to ``pyramid.request.Request.static_url`` when an external URL is being generated. See https://github.com/Pylons/pyramid/pull/1183 +- You can now send a string as the ``_query`` argument to + ``pyramid.request.Request.route_url`` and + ``pyramid.request.Request.resource_url`` and their derivatives. When a + string is sent instead of a list or dictionary. it is not URL-encoded or + quoted; the caller must perform this job before passing it in. This is + useful if you want to be able to use a different query string format than + ``x-www-form-urlencoded``. See https://github.com/Pylons/pyramid/pull/1183 + Bug Fixes --------- diff --git a/pyramid/tests/test_url.py b/pyramid/tests/test_url.py index 22ccd1d0e..0a788ba97 100644 --- a/pyramid/tests/test_url.py +++ b/pyramid/tests/test_url.py @@ -6,7 +6,6 @@ from pyramid import testing from pyramid.compat import ( text_, - native_, WIN, ) diff --git a/pyramid/url.py b/pyramid/url.py index 14f4add35..06e7e6a81 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -48,7 +48,7 @@ def parse_url_overrides(kw): if '_query' in kw: query = kw.pop('_query') if isinstance(query, string_types): - qs = '?' + url_quote(query, QUERY_SAFE) + qs = '?' + query elif query: qs = '?' + urlencode(query, doseq=True) @@ -167,22 +167,21 @@ class URLMethodsMixin(object): ``*remainder`` replacement value, it is tacked on to the URL after being URL-quoted-except-for-embedded-slashes. - If no ``_query`` keyword argument is provided, the request - query string will be returned in the URL. If it is present, it - will be used to compose a query string that will be tacked on - to the end of the URL, replacing any request query string. - The value of ``_query`` may be a sequence of two-tuples *or* - a data structure with an ``.items()`` method that returns a - sequence of two-tuples (presumably a dictionary). This data - structure will be turned into a query string per the - documentation of :func:`pyramid.url.urlencode` function. - Alternative encodings may be used by passing a string for ``_query`` - in which case it will be quoted as per :rfc:`3986#section-3.4` but - no other assumptions will be made about the data format. For example, - spaces will be escaped as ``%20`` instead of ``+``. - After the query data is turned into a query string, a leading - ``?`` is prepended, and the resulting string is appended to - the generated URL. + If no ``_query`` keyword argument is provided, the request query string + will be returned in the URL. If it is present, it will be used to + compose a query string that will be tacked on to the end of the URL, + replacing any request query string. The value of ``_query`` may be a + sequence of two-tuples *or* a data structure with an ``.items()`` + method that returns a sequence of two-tuples (presumably a dictionary). + This data structure will be turned into a query string per the + documentation of :func:`pyramid.url.urlencode` function. This will + produce a query string in the ``x-www-form-urlencoded`` format. A + non-``x-www-form-urlencoded`` encoding may be used by passing a + *string* value as ``_query`` in which case it will be used without + quoting or encoding; it is left up to the caller to do both and if he + does not, an invalid URL may be generated. After the query data is + turned into a query string, a leading ``?`` is prepended, and the + resulting string is appended to the generated URL. .. note:: @@ -361,17 +360,17 @@ class URLMethodsMixin(object): ``elements`` are used, the generated URL will *not* end in trailing a slash. - If a keyword argument ``query`` is present, it will be used to - compose a query string that will be tacked on to the end of the URL. - The value of ``query`` may be a sequence of two-tuples *or* a data - structure with an ``.items()`` method that returns a sequence of - two-tuples (presumably a dictionary). This data structure will be - turned into a query string per the documentation of - :func:``pyramid.url.urlencode`` function. - Alternative encodings may be used by passing a string for ``query`` - in which case it will be quoted as per :rfc:`3986#section-3.4` but - no other assumptions will be made about the data format. For example, - spaces will be escaped as ``%20`` instead of ``+``. + If a keyword argument ``query`` is present, it will be used to compose + a query string that will be tacked on to the end of the URL. The value + of ``query`` may be a sequence of two-tuples *or* a data structure with + an ``.items()`` method that returns a sequence of two-tuples + (presumably a dictionary). This data structure will be turned into a + query string per the documentation of :func:``pyramid.url.urlencode`` + function. This will produce a query string in the + ``x-www-form-urlencoded`` encoding. A non-``x-www-form-urlencoded`` + query string may be used by passing a *string* value as ``query`` in + which case it will be used without quoting or encoding; it is up to the + caller to do both and if he does not an invalid URL may be generated. After the query data is turned into a query string, a leading ``?`` is prepended, and the resulting string is appended to the generated URL. @@ -620,7 +619,7 @@ class URLMethodsMixin(object): if 'query' in kw: query = kw['query'] if isinstance(query, string_types): - qs = '?' + url_quote(query, QUERY_SAFE) + qs = '?' + query elif query: qs = '?' + urlencode(query, doseq=True) -- cgit v1.2.3 From ca419fb59ccf2174a87aba5139a293b807e15df6 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 27 Nov 2013 04:50:24 -0500 Subject: revert my reversion --- CHANGES.txt | 10 +++++----- pyramid/url.py | 26 ++++++++++++++------------ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 40efecce1..98784f3d7 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -50,7 +50,7 @@ Features - The anchor argument to ``pyramid.request.Request.route_url`` and ``pyramid.request.Request.resource_url`` and their derivatives will now be - escaped to ensure minimal conformance. See + escaped via URL quoting to ensure minimal conformance. See https://github.com/Pylons/pyramid/pull/1183 - Allow sending of ``_query`` and ``_anchor`` options to @@ -61,10 +61,10 @@ Features - You can now send a string as the ``_query`` argument to ``pyramid.request.Request.route_url`` and ``pyramid.request.Request.resource_url`` and their derivatives. When a - string is sent instead of a list or dictionary. it is not URL-encoded or - quoted; the caller must perform this job before passing it in. This is - useful if you want to be able to use a different query string format than - ``x-www-form-urlencoded``. See https://github.com/Pylons/pyramid/pull/1183 + string is sent instead of a list or dictionary. it is URL-quoted however it + does not need to be in ``k=v`` form. This is useful if you want to be able + to use a different query string format than ``x-www-form-urlencoded``. See + https://github.com/Pylons/pyramid/pull/1183 Bug Fixes --------- diff --git a/pyramid/url.py b/pyramid/url.py index 06e7e6a81..484ee775f 100644 --- a/pyramid/url.py +++ b/pyramid/url.py @@ -48,7 +48,7 @@ def parse_url_overrides(kw): if '_query' in kw: query = kw.pop('_query') if isinstance(query, string_types): - qs = '?' + query + qs = '?' + url_quote(query, QUERY_SAFE) elif query: qs = '?' + urlencode(query, doseq=True) @@ -176,12 +176,13 @@ class URLMethodsMixin(object): This data structure will be turned into a query string per the documentation of :func:`pyramid.url.urlencode` function. This will produce a query string in the ``x-www-form-urlencoded`` format. A - non-``x-www-form-urlencoded`` encoding may be used by passing a - *string* value as ``_query`` in which case it will be used without - quoting or encoding; it is left up to the caller to do both and if he - does not, an invalid URL may be generated. After the query data is - turned into a query string, a leading ``?`` is prepended, and the - resulting string is appended to the generated URL. + non-``x-www-form-urlencoded`` query string may be used by passing a + *string* value as ``_query`` in which case it will be URL-quoted + (e.g. query="foo bar" will become "foo%20bar"). However, the result + will not need to be in ``k=v`` form as required by + ``x-www-form-urlencoded``. After the query data is turned into a query + string, a leading ``?`` is prepended, and the resulting string is + appended to the generated URL. .. note:: @@ -369,10 +370,11 @@ class URLMethodsMixin(object): function. This will produce a query string in the ``x-www-form-urlencoded`` encoding. A non-``x-www-form-urlencoded`` query string may be used by passing a *string* value as ``query`` in - which case it will be used without quoting or encoding; it is up to the - caller to do both and if he does not an invalid URL may be generated. - After the query data is turned into a query string, a leading ``?`` is - prepended, and the resulting string is appended to the generated URL. + which case it will be URL-quoted (e.g. query="foo bar" will become + "foo%20bar"). However, the result will not need to be in ``k=v`` form + as required by ``x-www-form-urlencoded``. After the query data is + turned into a query string, a leading ``?`` is prepended, and the + resulting string is appended to the generated URL. .. note:: @@ -619,7 +621,7 @@ class URLMethodsMixin(object): if 'query' in kw: query = kw['query'] if isinstance(query, string_types): - qs = '?' + query + qs = '?' + url_quote(query, QUERY_SAFE) elif query: qs = '?' + urlencode(query, doseq=True) -- cgit v1.2.3 From 3a950cb42ee450a02d567b25bcb2847f586eabfa Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Wed, 27 Nov 2013 05:11:57 -0500 Subject: appease travis --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index bc82c8faf..29e499e76 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,8 @@ python: - 3.2 - 3.3 +install: python setup.py dev + script: python setup.py test -q notifications: -- cgit v1.2.3