From 753d596aa2c93bed1fc95fb71bbeef383646c2cb Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 17 Feb 2019 12:15:48 -0800 Subject: Add ISecurityPolicy interface. Deprecate IAuthenticationPolicy and IAuthorizationPolicy. --- src/pyramid/interfaces.py | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/pyramid/interfaces.py b/src/pyramid/interfaces.py index f1e238c6b..9dabb9cfc 100644 --- a/src/pyramid/interfaces.py +++ b/src/pyramid/interfaces.py @@ -482,8 +482,40 @@ class IViewMapperFactory(Interface): """ +class ISecurityPolicy(Interface): + def identify(request): + """ Return an object identifying a trusted and verified user. """ + + def permits(request, context, identity, permission): + """ Return an instance of :class:`pyramid.security.Allowed` if a user + of the given identity is allowed the ``permission`` in the current + ``context``, else return an instance of + :class:`pyramid.security.Denied`. + """ + + def remember(request, userid, **kw): + """ Return a set of headers suitable for 'remembering' the + :term:`userid` named ``userid`` when set in a response. An + individual authentication policy and its consumers can + decide on the composition and meaning of ``**kw``. + + """ + + def forget(request): + """ Return a set of headers suitable for 'forgetting' the + current user on subsequent requests. + + """ + + class IAuthenticationPolicy(Interface): - """ An object representing a Pyramid authentication policy. """ + """ An object representing a Pyramid authentication policy. + + .. deprecated:: 2.0 + + Use :class:`ISecurityPolicy`. + + """ def authenticated_userid(request): """ Return the authenticated :term:`userid` or ``None`` if @@ -536,7 +568,13 @@ class IAuthenticationPolicy(Interface): class IAuthorizationPolicy(Interface): - """ An object representing a Pyramid authorization policy. """ + """ An object representing a Pyramid authorization policy. + + .. deprecated:: 2.0 + + Use :class:`ISecurityPolicy`. + + """ def permits(context, principals, permission): """ Return an instance of :class:`pyramid.security.Allowed` if any -- cgit v1.2.3 From e47e7f457a6143dda28f9dd1674c53b1ece67f9d Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 17 Feb 2019 12:23:27 -0800 Subject: Add `security policy` and `identity` to glossary. Also mark authn/authz as deprecatd. --- docs/glossary.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/glossary.rst b/docs/glossary.rst index cd472a660..8a1d27734 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -306,6 +306,16 @@ Glossary a principal, but this is not strictly necessary in custom policies that define their principals differently. + identity + An identity is an opaque identifier of the user associated with the + current request. + + security policy + A security policy in :app:`Pyramid` terms is a bit of code which has an + API which identifies the user associated with the current request (perhaps + via a cookie or ``Authorization`` header) and determines whether or not + that user is permitted to access the requested resource. + authorization policy An authorization policy in :app:`Pyramid` terms is a bit of code which has an API which determines whether or not the @@ -313,11 +323,19 @@ Glossary associated with a permission, based on the information found on the :term:`context` resource. + .. deprecated:: 2.0 + Authorization policies have been deprecated in favor of a + :term:`security policy`. + authentication policy An authentication policy in :app:`Pyramid` terms is a bit of code which has an API which determines the current :term:`principal` (or principals) associated with a request. + .. deprecated:: 2.0 + Authentication policies have been deprecated in favor of a + :term:`security policy`. + WSGI `Web Server Gateway Interface `_. This is a Python standard for connecting web applications to web servers, -- cgit v1.2.3 From a6234e4e19efab838b202d0935de0de92c2ee00f Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 17 Feb 2019 12:44:02 -0800 Subject: Implement setting ISecurityPolicy in the configurator. --- src/pyramid/config/__init__.py | 14 ++++++++++++-- src/pyramid/config/security.py | 33 +++++++++++++++++++++++++++++++++ tests/test_config/test_init.py | 9 +++++++++ tests/test_config/test_security.py | 9 +++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/pyramid/config/__init__.py b/src/pyramid/config/__init__.py index 072b654c4..d8961268a 100644 --- a/src/pyramid/config/__init__.py +++ b/src/pyramid/config/__init__.py @@ -139,13 +139,17 @@ class Configurator( :term:`dotted Python name` to the same. If it is ``None``, a default root factory will be used. + If ``security_policy`` is passed, it should be an instance of a + :term:`security policy` or a :term:`dotted Python name` to the same. + If ``authentication_policy`` is passed, it should be an instance of an :term:`authentication policy` or a :term:`dotted Python - name` to the same. + name` to the same. (Deprecated as of Pyramid 2.0 in favor of + ``security_policy``.) If ``authorization_policy`` is passed, it should be an instance of an :term:`authorization policy` or a :term:`dotted Python name` to - the same. + the same. (Deprecated as of Pyramid 2.0 in favor of ``security_policy``.) .. note:: A ``ConfigurationError`` will be raised when an authorization policy is supplied without also supplying an @@ -278,6 +282,7 @@ class Configurator( package=None, settings=None, root_factory=None, + security_policy=None, authentication_policy=None, authorization_policy=None, renderers=None, @@ -315,6 +320,7 @@ class Configurator( root_factory=root_factory, authentication_policy=authentication_policy, authorization_policy=authorization_policy, + security_policy=security_policy, renderers=renderers, debug_logger=debug_logger, locale_negotiator=locale_negotiator, @@ -330,6 +336,7 @@ class Configurator( self, settings=None, root_factory=None, + security_policy=None, authentication_policy=None, authorization_policy=None, renderers=None, @@ -415,6 +422,9 @@ class Configurator( if authentication_policy: self.set_authentication_policy(authentication_policy) + if security_policy: + self.set_security_policy(security_policy) + if default_view_mapper is not None: self.set_view_mapper(default_view_mapper) diff --git a/src/pyramid/config/security.py b/src/pyramid/config/security.py index 08e7cb81a..b023917aa 100644 --- a/src/pyramid/config/security.py +++ b/src/pyramid/config/security.py @@ -6,6 +6,7 @@ from pyramid.interfaces import ( ICSRFStoragePolicy, IDefaultCSRFOptions, IDefaultPermission, + ISecurityPolicy, PHASE1_CONFIG, PHASE2_CONFIG, ) @@ -21,6 +22,38 @@ class SecurityConfiguratorMixin(object): def add_default_security(self): self.set_csrf_storage_policy(LegacySessionCSRFStoragePolicy()) + @action_method + def set_security_policy(self, policy): + """ Override the :app:`Pyramid` :term:`security policy` in the current + configuration. The ``policy`` argument must be an instance + of a security policy or a :term:`dotted Python name` + that points at an instance of a security policy. + + .. note:: + + Using the ``security_policy`` argument to the + :class:`pyramid.config.Configurator` constructor can be used to + achieve the same purpose. + + """ + + def register(): + self._set_security_policy(policy) + + intr = self.introspectable( + 'security policy', + None, + self.object_description(policy), + 'security policy', + ) + intr['policy'] = policy + # authentication policy used by view config (phase 3) + self.action(IAuthenticationPolicy, register, introspectables=(intr,)) + + def _set_security_policy(self, policy): + policy = self.maybe_dotted(policy) + self.registry.registerUtility(policy, ISecurityPolicy) + @action_method def set_authentication_policy(self, policy): """ Override the :app:`Pyramid` :term:`authentication policy` in the diff --git a/tests/test_config/test_init.py b/tests/test_config/test_init.py index ce2b042ec..661654ef0 100644 --- a/tests/test_config/test_init.py +++ b/tests/test_config/test_init.py @@ -205,6 +205,15 @@ class ConfiguratorTests(unittest.TestCase): result = config.registry.getUtility(IDebugLogger) self.assertEqual(logger, result) + def test_ctor_security_policy(self): + from pyramid.interfaces import ISecurityPolicy + + policy = object() + config = self._makeOne(security_policy=policy) + config.commit() + result = config.registry.getUtility(ISecurityPolicy) + self.assertEqual(policy, result) + def test_ctor_authentication_policy(self): from pyramid.interfaces import IAuthenticationPolicy diff --git a/tests/test_config/test_security.py b/tests/test_config/test_security.py index 5ebd78f8d..3062ea154 100644 --- a/tests/test_config/test_security.py +++ b/tests/test_config/test_security.py @@ -11,6 +11,15 @@ class ConfiguratorSecurityMethodsTests(unittest.TestCase): config = Configurator(*arg, **kw) return config + def test_set_security_policy(self): + from pyramid.interfaces import ISecurityPolicy + + config = self._makeOne() + policy = object() + config.set_security_policy(policy) + config.commit() + self.assertEqual(config.registry.getUtility(ISecurityPolicy), policy) + def test_set_authentication_policy_no_authz_policy(self): config = self._makeOne() policy = object() -- cgit v1.2.3 From 4c3c826ca9a6069f47fee439576966cf625df528 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 17 Feb 2019 16:38:53 -0800 Subject: Implement legacy security policy. --- src/pyramid/security.py | 36 +++++++++++++++++++++++++++++++++- tests/test_security.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 61819588b..abb9c7442 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -1,6 +1,7 @@ -from zope.interface import providedBy +from zope.interface import implementer, providedBy from pyramid.interfaces import ( + ISecurityPolicy, IAuthenticationPolicy, IAuthorizationPolicy, ISecuredView, @@ -363,3 +364,36 @@ class AuthorizationAPIMixin(object): ) # should never happen principals = authn_policy.effective_principals(self) return authz_policy.permits(context, principals, permission) + + +@implementer(ISecurityPolicy) +class LegacySecurityPolicy: + """ + A :term:`security policy` which provides a backwards compatibility shim for + the :term:`authentication policy` and the :term:`authorization policy`. + + """ + + def _get_authn_policy(self, request): + return request.registry.getUtility(IAuthenticationPolicy) + + def _get_authz_policy(self, request): + return request.registry.getUtility(IAuthorizationPolicy) + + def identify(self, request): + authn = self._get_authn_policy(request) + return authn.authenticated_userid(request) + + def remember(self, request, userid, **kw): + authn = self._get_authn_policy(request) + return authn.remember(request, userid, **kw) + + def forget(self, request): + authn = self._get_authn_policy(request) + return authn.forget(request) + + def permits(self, request, context, identity, permission): + authn = self._get_authn_policy(request) + authz = self._get_authz_policy(request) + principals = authn.effective_principals(request) + return authz.permits(context, principals, permission) diff --git a/tests/test_security.py b/tests/test_security.py index 8b8028f61..ee4340ced 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -473,6 +473,58 @@ class TestHasPermission(unittest.TestCase): self.assertRaises(AttributeError, request.has_permission, 'view') +class TestLegacySecurityPolicy(unittest.TestCase): + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + + def test_identity(self): + from pyramid.security import LegacySecurityPolicy + + request = _makeRequest() + policy = LegacySecurityPolicy() + _registerAuthenticationPolicy(request.registry, 'userid') + + self.assertEqual(policy.identify(request), 'userid') + + def test_remember(self): + from pyramid.security import LegacySecurityPolicy + + request = _makeRequest() + policy = LegacySecurityPolicy() + _registerAuthenticationPolicy(request.registry, None) + + self.assertEqual( + policy.remember(request, 'userid'), [('X-Pyramid-Test', 'userid')] + ) + + def test_forget(self): + from pyramid.security import LegacySecurityPolicy + + request = _makeRequest() + policy = LegacySecurityPolicy() + _registerAuthenticationPolicy(request.registry, None) + + self.assertEqual( + policy.forget(request), [('X-Pyramid-Test', 'logout')] + ) + + def test_permits(self): + from pyramid.security import LegacySecurityPolicy + + request = _makeRequest() + policy = LegacySecurityPolicy() + _registerAuthenticationPolicy(request.registry, ['p1', 'p2']) + _registerAuthorizationPolicy(request.registry, True) + + self.assertIs( + policy.permits(request, request.context, 'userid', 'permission'), + True, + ) + + _TEST_HEADER = 'X-Pyramid-Test' -- cgit v1.2.3 From 839bfb427093b94a5e75b22a11a5df20ad94cb1e Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 17 Feb 2019 19:02:33 -0800 Subject: Set legacy policy when using authn/authz policies. --- src/pyramid/config/security.py | 11 +++++++++++ tests/test_config/test_security.py | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/pyramid/config/security.py b/src/pyramid/config/security.py index b023917aa..42554db97 100644 --- a/src/pyramid/config/security.py +++ b/src/pyramid/config/security.py @@ -14,6 +14,7 @@ from pyramid.interfaces import ( from pyramid.csrf import LegacySessionCSRFStoragePolicy from pyramid.exceptions import ConfigurationError from pyramid.util import as_sorted_tuple +from pyramid.security import LegacySecurityPolicy from pyramid.config.actions import action_method @@ -77,6 +78,7 @@ class SecurityConfiguratorMixin(object): 'also configuring an authorization policy ' '(use the set_authorization_policy method)' ) + self._set_legacy_policy() intr = self.introspectable( 'authentication policy', @@ -97,6 +99,15 @@ class SecurityConfiguratorMixin(object): policy = self.maybe_dotted(policy) self.registry.registerUtility(policy, IAuthenticationPolicy) + def _set_legacy_policy(self): + if self.registry.queryUtility(ISecurityPolicy) is not None: + raise ConfigurationError( + 'Cannot configure an authentication and authorization policy ' + 'with a configured security policy.' + ) + policy = LegacySecurityPolicy() + self.registry.registerUtility(policy, ISecurityPolicy) + @action_method def set_authorization_policy(self, policy): """ Override the :app:`Pyramid` :term:`authorization policy` in the diff --git a/tests/test_config/test_security.py b/tests/test_config/test_security.py index 3062ea154..f2b4ba8e5 100644 --- a/tests/test_config/test_security.py +++ b/tests/test_config/test_security.py @@ -20,6 +20,19 @@ class ConfiguratorSecurityMethodsTests(unittest.TestCase): config.commit() self.assertEqual(config.registry.getUtility(ISecurityPolicy), policy) + def test_set_authentication_policy_with_security_policy(self): + from pyramid.interfaces import IAuthorizationPolicy + from pyramid.interfaces import ISecurityPolicy + + config = self._makeOne() + security_policy = object() + authn_policy = object() + authz_policy = object() + config.registry.registerUtility(security_policy, ISecurityPolicy) + config.registry.registerUtility(authz_policy, IAuthorizationPolicy) + config.set_authentication_policy(authn_policy) + self.assertRaises(ConfigurationError, config.commit) + def test_set_authentication_policy_no_authz_policy(self): config = self._makeOne() policy = object() @@ -36,6 +49,8 @@ class ConfiguratorSecurityMethodsTests(unittest.TestCase): def test_set_authentication_policy_with_authz_policy(self): from pyramid.interfaces import IAuthenticationPolicy from pyramid.interfaces import IAuthorizationPolicy + from pyramid.interfaces import ISecurityPolicy + from pyramid.security import LegacySecurityPolicy config = self._makeOne() authn_policy = object() @@ -46,10 +61,15 @@ class ConfiguratorSecurityMethodsTests(unittest.TestCase): self.assertEqual( config.registry.getUtility(IAuthenticationPolicy), authn_policy ) + self.assertIsInstance( + config.registry.getUtility(ISecurityPolicy), LegacySecurityPolicy + ) def test_set_authentication_policy_with_authz_policy_autocommit(self): from pyramid.interfaces import IAuthenticationPolicy from pyramid.interfaces import IAuthorizationPolicy + from pyramid.interfaces import ISecurityPolicy + from pyramid.security import LegacySecurityPolicy config = self._makeOne(autocommit=True) authn_policy = object() @@ -60,6 +80,9 @@ class ConfiguratorSecurityMethodsTests(unittest.TestCase): self.assertEqual( config.registry.getUtility(IAuthenticationPolicy), authn_policy ) + self.assertIsInstance( + config.registry.getUtility(ISecurityPolicy), LegacySecurityPolicy + ) def test_set_authorization_policy_no_authn_policy(self): config = self._makeOne() -- cgit v1.2.3 From f1709eb287f6a2a4bf7504d5d18c2e7a529636f1 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 17 Feb 2019 19:05:08 -0800 Subject: Sign CONTRIBUTORS.txt. --- CONTRIBUTORS.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 79e4287d2..42a1f8cc2 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -336,3 +336,5 @@ Contributors - Colin Dunklau, 2018/09/19 - Alexandre Yukio Harano, 2018/10/05 + +- Theron Luhn, 2019/02/17 -- cgit v1.2.3 From aae1c513c0a940c1c31c97b4d79fec3d09cbd01e Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Tue, 26 Feb 2019 17:49:36 -0800 Subject: Add `request.identity`. --- src/pyramid/request.py | 5 ++++- src/pyramid/security.py | 32 +++++++++++++++++++++++++++++--- src/pyramid/testing.py | 2 ++ tests/test_security.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/pyramid/request.py b/src/pyramid/request.py index b9bd7451a..726f485e7 100644 --- a/src/pyramid/request.py +++ b/src/pyramid/request.py @@ -15,7 +15,9 @@ from pyramid.interfaces import ( from pyramid.decorator import reify from pyramid.i18n import LocalizerRequestMixin from pyramid.response import Response, _get_response_factory -from pyramid.security import AuthenticationAPIMixin, AuthorizationAPIMixin +from pyramid.security import ( + SecurityAPIMixin, AuthenticationAPIMixin, AuthorizationAPIMixin, +) from pyramid.url import URLMethodsMixin from pyramid.util import ( InstancePropertyHelper, @@ -147,6 +149,7 @@ class Request( CallbackMethodsMixin, InstancePropertyMixin, LocalizerRequestMixin, + SecurityAPIMixin, AuthenticationAPIMixin, AuthorizationAPIMixin, ViewMethodsMixin, diff --git a/src/pyramid/security.py b/src/pyramid/security.py index abb9c7442..efc0c193c 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -44,6 +44,11 @@ def _get_registry(request): return reg +def _get_security_policy(request): + registry = _get_registry(request) + return registry.queryUtility(ISecurityPolicy) + + def _get_authentication_policy(request): registry = _get_registry(request) return registry.queryUtility(IAuthenticationPolicy) @@ -281,6 +286,20 @@ class ACLAllowed(ACLPermitsResult, Allowed): """ +class SecurityAPIMixin(object): + @property + def identity(self): + """ + Return an opaque object identifying the current user or ``None`` if no + user is authenticated or there is no :term:`security policy` in effect. + + """ + policy = _get_security_policy(self) + if policy is None: + return None + return policy.identify(self) + + class AuthenticationAPIMixin(object): @property def authenticated_userid(self): @@ -288,7 +307,10 @@ class AuthenticationAPIMixin(object): ``None`` if there is no :term:`authentication policy` in effect or there is no currently authenticated user. - .. versionadded:: 1.5 + .. deprecated:: 2.0 + + Use ``request.identity`` instead. + """ policy = _get_authentication_policy(self) if policy is None: @@ -305,7 +327,10 @@ class AuthenticationAPIMixin(object): effective authentication policy will not ensure that a record associated with the userid exists in persistent storage. - .. versionadded:: 1.5 + .. deprecated:: 2.0 + + Use ``request.identity`` instead. + """ policy = _get_authentication_policy(self) if policy is None: @@ -319,7 +344,8 @@ class AuthenticationAPIMixin(object): this will return a one-element list containing the :data:`pyramid.security.Everyone` principal. - .. versionadded:: 1.5 + .. deprecated:: 2.0 + """ policy = _get_authentication_policy(self) if policy is None: diff --git a/src/pyramid/testing.py b/src/pyramid/testing.py index ffddd233f..7a85aff85 100644 --- a/src/pyramid/testing.py +++ b/src/pyramid/testing.py @@ -17,6 +17,7 @@ from pyramid.registry import Registry from pyramid.security import ( Authenticated, Everyone, + SecurityAPIMixin, AuthenticationAPIMixin, AuthorizationAPIMixin, ) @@ -303,6 +304,7 @@ class DummyRequest( CallbackMethodsMixin, InstancePropertyMixin, LocalizerRequestMixin, + SecurityAPIMixin, AuthenticationAPIMixin, AuthorizationAPIMixin, ViewMethodsMixin, diff --git a/tests/test_security.py b/tests/test_security.py index ee4340ced..514175a92 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -338,6 +338,23 @@ class TestViewExecutionPermitted(unittest.TestCase): self.assertTrue(result) +class TestIdentity(unittest.TestCase): + def setUp(self): + testing.setUp() + + def tearDown(self): + testing.tearDown() + + def test_identity_no_security_policy(self): + request = _makeRequest() + self.assertEquals(request.identity, None) + + def test_identity(self): + request = _makeRequest() + _registerSecurityPolicy(request.registry, 'yo') + self.assertEqual(request.identity, 'yo') + + class TestAuthenticatedUserId(unittest.TestCase): def setUp(self): testing.setUp() @@ -533,6 +550,27 @@ class DummyContext: self.__dict__.update(kw) +class DummySecurityPolicy: + def __init__(self, result): + self.result = result + + def identify(self, request): + return self.result + + def permits(self, request, context, identity, permission): + return self.result + + def remember(self, request, userid, **kw): + headers = [(_TEST_HEADER, userid)] + self._header_remembered = headers[0] + return headers + + def forget(self, request): + headers = [(_TEST_HEADER, 'logout')] + self._header_forgotten = headers[0] + return headers + + class DummyAuthenticationPolicy: def __init__(self, result): self.result = result @@ -568,6 +606,14 @@ class DummyAuthorizationPolicy: return self.result +def _registerSecurityPolicy(reg, result): + from pyramid.interfaces import ISecurityPolicy + + policy = DummySecurityPolicy(result) + reg.registerUtility(policy, ISecurityPolicy) + return policy + + def _registerAuthenticationPolicy(reg, result): from pyramid.interfaces import IAuthenticationPolicy -- cgit v1.2.3 From 140fdbb54c467159313ede564dd3ad4077e30f20 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 2 Mar 2019 11:26:37 -0800 Subject: Implement bw-compat authenticated_userid and unauthenticated_userid --- src/pyramid/request.py | 4 +++- src/pyramid/security.py | 20 ++++++++++++++------ tests/test_security.py | 12 ++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/pyramid/request.py b/src/pyramid/request.py index 726f485e7..bb0dcaa2b 100644 --- a/src/pyramid/request.py +++ b/src/pyramid/request.py @@ -16,7 +16,9 @@ from pyramid.decorator import reify from pyramid.i18n import LocalizerRequestMixin from pyramid.response import Response, _get_response_factory from pyramid.security import ( - SecurityAPIMixin, AuthenticationAPIMixin, AuthorizationAPIMixin, + SecurityAPIMixin, + AuthenticationAPIMixin, + AuthorizationAPIMixin, ) from pyramid.url import URLMethodsMixin from pyramid.util import ( diff --git a/src/pyramid/security.py b/src/pyramid/security.py index efc0c193c..66e314f79 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -312,10 +312,14 @@ class AuthenticationAPIMixin(object): Use ``request.identity`` instead. """ - policy = _get_authentication_policy(self) - if policy is None: + authn = _get_authentication_policy(self) + security = _get_security_policy(self) + if authn is not None: + return authn.authenticated_userid(self) + elif security is not None: + return security.identify(self) + else: return None - return policy.authenticated_userid(self) @property def unauthenticated_userid(self): @@ -332,10 +336,14 @@ class AuthenticationAPIMixin(object): Use ``request.identity`` instead. """ - policy = _get_authentication_policy(self) - if policy is None: + authn = _get_authentication_policy(self) + security = _get_security_policy(self) + if authn is not None: + return authn.unauthenticated_userid(self) + elif security is not None: + return security.identify(self) + else: return None - return policy.unauthenticated_userid(self) @property def effective_principals(self): diff --git a/tests/test_security.py b/tests/test_security.py index 514175a92..dd2c225d3 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -369,6 +369,12 @@ class TestAuthenticatedUserId(unittest.TestCase): def test_with_authentication_policy(self): request = _makeRequest() _registerAuthenticationPolicy(request.registry, 'yo') + _registerSecurityPolicy(request.registry, 'wat') + self.assertEqual(request.authenticated_userid, 'yo') + + def test_with_security_policy(self): + request = _makeRequest() + _registerSecurityPolicy(request.registry, 'yo') self.assertEqual(request.authenticated_userid, 'yo') def test_with_authentication_policy_no_reg_on_request(self): @@ -395,6 +401,12 @@ class TestUnAuthenticatedUserId(unittest.TestCase): def test_with_authentication_policy(self): request = _makeRequest() _registerAuthenticationPolicy(request.registry, 'yo') + _registerSecurityPolicy(request.registry, 'wat') + self.assertEqual(request.unauthenticated_userid, 'yo') + + def test_with_security_policy(self): + request = _makeRequest() + _registerSecurityPolicy(request.registry, 'yo') self.assertEqual(request.unauthenticated_userid, 'yo') def test_with_authentication_policy_no_reg_on_request(self): -- cgit v1.2.3 From 5abdd1d7636a8f7c5cda4c8fcf2669c3937c1186 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 3 Mar 2019 08:49:58 -0800 Subject: Implement new request.has_permission. Deleted AuthorizationAPIMixin --- src/pyramid/request.py | 7 +--- src/pyramid/security.py | 67 ++++++++++++++++----------------------- src/pyramid/testing.py | 2 -- tests/test_config/test_testing.py | 4 +-- tests/test_request.py | 4 +-- tests/test_security.py | 30 ++++-------------- 6 files changed, 39 insertions(+), 75 deletions(-) diff --git a/src/pyramid/request.py b/src/pyramid/request.py index bb0dcaa2b..5c68abe69 100644 --- a/src/pyramid/request.py +++ b/src/pyramid/request.py @@ -15,11 +15,7 @@ from pyramid.interfaces import ( from pyramid.decorator import reify from pyramid.i18n import LocalizerRequestMixin from pyramid.response import Response, _get_response_factory -from pyramid.security import ( - SecurityAPIMixin, - AuthenticationAPIMixin, - AuthorizationAPIMixin, -) +from pyramid.security import SecurityAPIMixin, AuthenticationAPIMixin from pyramid.url import URLMethodsMixin from pyramid.util import ( InstancePropertyHelper, @@ -153,7 +149,6 @@ class Request( LocalizerRequestMixin, SecurityAPIMixin, AuthenticationAPIMixin, - AuthorizationAPIMixin, ViewMethodsMixin, ): """ diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 66e314f79..4881d94a6 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -299,6 +299,34 @@ class SecurityAPIMixin(object): return None return policy.identify(self) + 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 + security policy. Returns + :data:`pyramid.security.Allowed` unconditionally if no security + 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: str + :param context: A resource object or ``None`` + :type context: object + :returns: Either :class:`pyramid.security.Allowed` or + :class:`pyramid.security.Denied`. + + """ + if context is None: + context = self.context + policy = _get_security_policy(self) + if policy is None: + return Allowed('No security policy in use.') + identity = policy.identify(self) + return policy.permits(self, context, identity, permission) + class AuthenticationAPIMixin(object): @property @@ -361,45 +389,6 @@ class AuthenticationAPIMixin(object): return policy.effective_principals(self) -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. 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: str - :param context: A resource object or ``None`` - :type context: object - :returns: Either :class:`pyramid.security.Allowed` or - :class:`pyramid.security.Denied`. - - .. versionadded:: 1.5 - - """ - 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) - - @implementer(ISecurityPolicy) class LegacySecurityPolicy: """ diff --git a/src/pyramid/testing.py b/src/pyramid/testing.py index 7a85aff85..90a49c04a 100644 --- a/src/pyramid/testing.py +++ b/src/pyramid/testing.py @@ -19,7 +19,6 @@ from pyramid.security import ( Everyone, SecurityAPIMixin, AuthenticationAPIMixin, - AuthorizationAPIMixin, ) from pyramid.threadlocal import get_current_registry, manager @@ -306,7 +305,6 @@ class DummyRequest( LocalizerRequestMixin, SecurityAPIMixin, AuthenticationAPIMixin, - AuthorizationAPIMixin, ViewMethodsMixin, ): """ A DummyRequest object (incompletely) imitates a :term:`request` object. diff --git a/tests/test_config/test_testing.py b/tests/test_config/test_testing.py index 0fb73d268..822eeac8f 100644 --- a/tests/test_config/test_testing.py +++ b/tests/test_config/test_testing.py @@ -1,7 +1,7 @@ import unittest from zope.interface import implementer -from pyramid.security import AuthenticationAPIMixin, AuthorizationAPIMixin +from pyramid.security import SecurityAPIMixin, AuthenticationAPIMixin from pyramid.util import text_ from . import IDummy @@ -232,7 +232,7 @@ class DummyEvent: pass -class DummyRequest(AuthenticationAPIMixin, AuthorizationAPIMixin): +class DummyRequest(SecurityAPIMixin, AuthenticationAPIMixin): def __init__(self, environ=None): if environ is None: environ = {} diff --git a/tests/test_request.py b/tests/test_request.py index 484d86e01..1a10a8509 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -1,7 +1,7 @@ import unittest from pyramid import testing -from pyramid.security import AuthenticationAPIMixin, AuthorizationAPIMixin +from pyramid.security import SecurityAPIMixin, AuthenticationAPIMixin from pyramid.util import text_, bytes_ @@ -54,7 +54,7 @@ class TestRequest(unittest.TestCase): self.assertEqual(cls.ResponseClass, Response) def test_implements_security_apis(self): - apis = (AuthenticationAPIMixin, AuthorizationAPIMixin) + apis = (SecurityAPIMixin, AuthenticationAPIMixin) r = self._makeOne() self.assertTrue(isinstance(r, apis)) diff --git a/tests/test_security.py b/tests/test_security.py index dd2c225d3..40b5cd061 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -455,43 +455,25 @@ class TestHasPermission(unittest.TestCase): testing.tearDown() def _makeOne(self): - from pyramid.security import AuthorizationAPIMixin + from pyramid.security import SecurityAPIMixin from pyramid.registry import Registry - mixin = AuthorizationAPIMixin() + mixin = SecurityAPIMixin() mixin.registry = Registry() mixin.context = object() return mixin - def test_no_authentication_policy(self): + def test_no_security_policy(self): request = self._makeOne() result = request.has_permission('view') self.assertTrue(result) - self.assertEqual(result.msg, 'No authentication policy in use.') + self.assertEqual(result.msg, 'No security policy in use.') - def test_with_no_authorization_policy(self): + def test_with_security_registered(self): request = self._makeOne() - _registerAuthenticationPolicy(request.registry, None) - self.assertRaises( - ValueError, request.has_permission, 'view', context=None - ) - - def test_with_authn_and_authz_policies_registered(self): - request = self._makeOne() - _registerAuthenticationPolicy(request.registry, None) - _registerAuthorizationPolicy(request.registry, 'yo') + _registerSecurityPolicy(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 = 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')) -- cgit v1.2.3 From edf7ef0c379361f3a056014b068a01657decfb76 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 9 Mar 2019 12:02:24 -0800 Subject: Implement secured view deriver. Some tests still need fixing. --- src/pyramid/viewderivers.py | 36 ++++++------ tests/test_viewderivers.py | 134 ++++++++------------------------------------ 2 files changed, 38 insertions(+), 132 deletions(-) diff --git a/src/pyramid/viewderivers.py b/src/pyramid/viewderivers.py index 181cc9e5c..22659d2a3 100644 --- a/src/pyramid/viewderivers.py +++ b/src/pyramid/viewderivers.py @@ -7,12 +7,11 @@ from pyramid.csrf import check_csrf_origin, check_csrf_token from pyramid.response import Response from pyramid.interfaces import ( - IAuthenticationPolicy, - IAuthorizationPolicy, IDefaultCSRFOptions, IDefaultPermission, IDebugLogger, IResponse, + ISecurityPolicy, IViewMapper, IViewMapperFactory, ) @@ -308,19 +307,17 @@ def _secured_view(view, info): # permission, replacing it with no permission at all permission = None - wrapped_view = view - authn_policy = info.registry.queryUtility(IAuthenticationPolicy) - authz_policy = info.registry.queryUtility(IAuthorizationPolicy) + policy = info.registry.queryUtility(ISecurityPolicy) # no-op on exception-only views without an explicit permission if explicit_val is None and info.exception_only: return view - if authn_policy and authz_policy and (permission is not None): + if policy and (permission is not None): def permitted(context, request): - principals = authn_policy.effective_principals(request) - return authz_policy.permits(context, principals, permission) + identity = policy.identify(request) + return policy.permits(request, context, identity, permission) def secured_view(context, request): result = permitted(context, request) @@ -334,12 +331,12 @@ def _secured_view(view, info): ) raise HTTPForbidden(msg, result=result) - wrapped_view = secured_view - wrapped_view.__call_permissive__ = view - wrapped_view.__permitted__ = permitted - wrapped_view.__permission__ = permission - - return wrapped_view + secured_view.__call_permissive__ = view + secured_view.__permitted__ = permitted + secured_view.__permission__ = permission + return secured_view + else: + return view def _authdebug_view(view, info): @@ -348,8 +345,7 @@ def _authdebug_view(view, info): permission = explicit_val = info.options.get('permission') if permission is None: permission = info.registry.queryUtility(IDefaultPermission) - authn_policy = info.registry.queryUtility(IAuthenticationPolicy) - authz_policy = info.registry.queryUtility(IAuthorizationPolicy) + policy = info.registry.queryUtility(ISecurityPolicy) logger = info.registry.queryUtility(IDebugLogger) # no-op on exception-only views without an explicit permission @@ -361,18 +357,18 @@ def _authdebug_view(view, info): def authdebug_view(context, request): view_name = getattr(request, 'view_name', None) - if authn_policy and authz_policy: + if policy: if permission is NO_PERMISSION_REQUIRED: msg = 'Allowed (NO_PERMISSION_REQUIRED)' elif permission is None: msg = 'Allowed (no permission registered)' else: - principals = authn_policy.effective_principals(request) + identity = policy.identify(request) msg = str( - authz_policy.permits(context, principals, permission) + policy.permits(request, context, identity, permission) ) else: - msg = 'Allowed (no authorization policy in use)' + msg = 'Allowed (no security policy in use)' view_name = getattr(request, 'view_name', None) url = getattr(request, 'url', None) diff --git a/tests/test_viewderivers.py b/tests/test_viewderivers.py index f01cb490e..9a61ea9f1 100644 --- a/tests/test_viewderivers.py +++ b/tests/test_viewderivers.py @@ -28,12 +28,11 @@ class TestDeriveView(unittest.TestCase): return logger def _registerSecurityPolicy(self, permissive): - from pyramid.interfaces import IAuthenticationPolicy - from pyramid.interfaces import IAuthorizationPolicy + from pyramid.interfaces import ISecurityPolicy policy = DummySecurityPolicy(permissive) - self.config.registry.registerUtility(policy, IAuthenticationPolicy) - self.config.registry.registerUtility(policy, IAuthorizationPolicy) + self.config.registry.registerUtility(policy, ISecurityPolicy) + return policy def test_function_returns_non_adaptable(self): def view(request): @@ -421,7 +420,7 @@ class TestDeriveView(unittest.TestCase): self.assertFalse(hasattr(result, '__call_permissive__')) self.assertEqual(result(None, None), response) - def test_with_debug_authorization_no_authpol(self): + def test_with_debug_authorization_no_security_policy(self): response = DummyResponse() view = lambda *arg: response self.config.registry.settings = dict( @@ -442,59 +441,7 @@ class TestDeriveView(unittest.TestCase): logger.messages[0], "debug_authorization of url url (view name " "'view_name' against context None): Allowed " - "(no authorization policy in use)", - ) - - def test_with_debug_authorization_authn_policy_no_authz_policy(self): - response = DummyResponse() - view = lambda *arg: response - self.config.registry.settings = dict(debug_authorization=True) - from pyramid.interfaces import IAuthenticationPolicy - - policy = DummySecurityPolicy(False) - self.config.registry.registerUtility(policy, IAuthenticationPolicy) - logger = self._registerLogger() - result = self.config._derive_view(view, permission='view') - self.assertEqual(view.__module__, result.__module__) - self.assertEqual(view.__doc__, result.__doc__) - self.assertEqual(view.__name__, result.__name__) - self.assertFalse(hasattr(result, '__call_permissive__')) - request = self._makeRequest() - request.view_name = 'view_name' - request.url = 'url' - self.assertEqual(result(None, request), response) - self.assertEqual(len(logger.messages), 1) - self.assertEqual( - logger.messages[0], - "debug_authorization of url url (view name " - "'view_name' against context None): Allowed " - "(no authorization policy in use)", - ) - - def test_with_debug_authorization_authz_policy_no_authn_policy(self): - response = DummyResponse() - view = lambda *arg: response - self.config.registry.settings = dict(debug_authorization=True) - from pyramid.interfaces import IAuthorizationPolicy - - policy = DummySecurityPolicy(False) - self.config.registry.registerUtility(policy, IAuthorizationPolicy) - logger = self._registerLogger() - result = self.config._derive_view(view, permission='view') - self.assertEqual(view.__module__, result.__module__) - self.assertEqual(view.__doc__, result.__doc__) - self.assertEqual(view.__name__, result.__name__) - self.assertFalse(hasattr(result, '__call_permissive__')) - request = self._makeRequest() - request.view_name = 'view_name' - request.url = 'url' - self.assertEqual(result(None, request), response) - self.assertEqual(len(logger.messages), 1) - self.assertEqual( - logger.messages[0], - "debug_authorization of url url (view name " - "'view_name' against context None): Allowed " - "(no authorization policy in use)", + "(no security policy in use)", ) def test_with_debug_authorization_no_permission(self): @@ -665,32 +612,11 @@ class TestDeriveView(unittest.TestCase): "'view_name' against context Exception()): True", ) - def test_secured_view_authn_policy_no_authz_policy(self): + def test_secured_view_authn_policy_no_security_policy(self): response = DummyResponse() view = lambda *arg: response self.config.registry.settings = {} - from pyramid.interfaces import IAuthenticationPolicy - policy = DummySecurityPolicy(False) - self.config.registry.registerUtility(policy, IAuthenticationPolicy) - result = self.config._derive_view(view, permission='view') - self.assertEqual(view.__module__, result.__module__) - self.assertEqual(view.__doc__, result.__doc__) - self.assertEqual(view.__name__, result.__name__) - self.assertFalse(hasattr(result, '__call_permissive__')) - request = self._makeRequest() - request.view_name = 'view_name' - request.url = 'url' - self.assertEqual(result(None, request), response) - - def test_secured_view_authz_policy_no_authn_policy(self): - response = DummyResponse() - view = lambda *arg: response - self.config.registry.settings = {} - from pyramid.interfaces import IAuthorizationPolicy - - policy = DummySecurityPolicy(False) - self.config.registry.registerUtility(policy, IAuthorizationPolicy) result = self.config._derive_view(view, permission='view') self.assertEqual(view.__module__, result.__module__) self.assertEqual(view.__doc__, result.__doc__) @@ -702,53 +628,41 @@ class TestDeriveView(unittest.TestCase): self.assertEqual(result(None, request), response) def test_secured_view_raises_forbidden_no_name(self): - from pyramid.interfaces import IAuthenticationPolicy - from pyramid.interfaces import IAuthorizationPolicy from pyramid.httpexceptions import HTTPForbidden response = DummyResponse() view = lambda *arg: response self.config.registry.settings = {} - policy = DummySecurityPolicy(False) - self.config.registry.registerUtility(policy, IAuthenticationPolicy) - self.config.registry.registerUtility(policy, IAuthorizationPolicy) + self._registerSecurityPolicy(False) result = self.config._derive_view(view, permission='view') request = self._makeRequest() request.view_name = 'view_name' request.url = 'url' - try: + with self.assertRaises(HTTPForbidden) as cm: result(None, request) - except HTTPForbidden as e: - self.assertEqual( - e.message, 'Unauthorized: failed permission check' - ) - else: # pragma: no cover - raise AssertionError + self.assertEqual( + cm.exception.message, + 'Unauthorized: failed permission check', + ) def test_secured_view_raises_forbidden_with_name(self): - from pyramid.interfaces import IAuthenticationPolicy - from pyramid.interfaces import IAuthorizationPolicy from pyramid.httpexceptions import HTTPForbidden def myview(request): # pragma: no cover pass self.config.registry.settings = {} - policy = DummySecurityPolicy(False) - self.config.registry.registerUtility(policy, IAuthenticationPolicy) - self.config.registry.registerUtility(policy, IAuthorizationPolicy) + self._registerSecurityPolicy(False) result = self.config._derive_view(myview, permission='view') request = self._makeRequest() request.view_name = 'view_name' request.url = 'url' - try: + with self.assertRaises(HTTPForbidden) as cm: result(None, request) - except HTTPForbidden as e: - self.assertEqual( - e.message, 'Unauthorized: myview failed permission check' - ) - else: # pragma: no cover - raise AssertionError + self.assertEqual( + cm.exception.message, + 'Unauthorized: myview failed permission check', + ) def test_secured_view_skipped_by_default_on_exception_view(self): from pyramid.request import Request @@ -794,12 +708,8 @@ class TestDeriveView(unittest.TestCase): app = self.config.make_wsgi_app() request = Request.blank('/foo', base_url='http://example.com') request.method = 'POST' - try: + with self.assertRaises(HTTPForbidden): request.get_response(app) - except HTTPForbidden: - pass - else: # pragma: no cover - raise AssertionError def test_secured_view_passed_on_explicit_exception_view(self): from pyramid.request import Request @@ -2130,10 +2040,10 @@ class DummySecurityPolicy: def __init__(self, permitted=True): self.permitted = permitted - def effective_principals(self, request): - return [] + def identify(self, request): + return 123 - def permits(self, context, principals, permission): + def permits(self, request, context, identity, permission): return self.permitted -- cgit v1.2.3 From 027d3cbd461be0555e7f4e44b508428228a4b56f Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 9 Mar 2019 12:39:41 -0800 Subject: Revamp tests for EffectivePrincipalsPredicate. --- src/pyramid/predicates.py | 7 +++++++ tests/test_predicates.py | 22 +++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/pyramid/predicates.py b/src/pyramid/predicates.py index 5a1127fb3..974f41cc5 100644 --- a/src/pyramid/predicates.py +++ b/src/pyramid/predicates.py @@ -291,6 +291,13 @@ class PhysicalPathPredicate(object): class EffectivePrincipalsPredicate(object): + """ + .. deprecated:: 2.0 + + No longer applicable with the new :term:`security policy`. + + """ + def __init__(self, val, config): if is_nonstr_iter(val): self.val = set(val) diff --git a/tests/test_predicates.py b/tests/test_predicates.py index a99651a8f..60e36047e 100644 --- a/tests/test_predicates.py +++ b/tests/test_predicates.py @@ -502,6 +502,22 @@ class Test_EffectivePrincipalsPredicate(unittest.TestCase): return EffectivePrincipalsPredicate(val, config) + def _testing_authn_policy(self, userid, groupids=tuple()): + from pyramid.interfaces import IAuthenticationPolicy + from pyramid.security import Everyone, Authenticated + + class DummyPolicy: + def effective_principals(self, request): + p = [Everyone] + if userid: + p.append(Authenticated) + p.append(userid) + p.extend(groupids) + return p + + registry = self.config.registry + registry.registerUtility(DummyPolicy(), IAuthenticationPolicy) + def test_text(self): inst = self._makeOne(('verna', 'fred'), None) self.assertEqual( @@ -526,7 +542,7 @@ class Test_EffectivePrincipalsPredicate(unittest.TestCase): def test_it_call_authentication_policy_provides_superset(self): request = testing.DummyRequest() - self.config.testing_securitypolicy('fred', groupids=('verna', 'bambi')) + self._testing_authn_policy('fred', groupids=('verna', 'bambi')) inst = self._makeOne(('verna', 'fred'), None) context = Dummy() self.assertTrue(inst(context, request)) @@ -535,14 +551,14 @@ class Test_EffectivePrincipalsPredicate(unittest.TestCase): from pyramid.security import Authenticated request = testing.DummyRequest() - self.config.testing_securitypolicy('fred', groupids=('verna', 'bambi')) + self._testing_authn_policy('fred', groupids=('verna', 'bambi')) inst = self._makeOne(Authenticated, None) context = Dummy() self.assertTrue(inst(context, request)) def test_it_call_authentication_policy_doesnt_provide_superset(self): request = testing.DummyRequest() - self.config.testing_securitypolicy('fred') + self._testing_authn_policy('fred') inst = self._makeOne(('verna', 'fred'), None) context = Dummy() self.assertFalse(inst(context, request)) -- cgit v1.2.3 From 7cdaabd655b3ba1deba6c22ef7c956529e51cb79 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 3 Mar 2019 09:01:12 -0800 Subject: Reimplement remember and forget. This breaks some more tests. --- src/pyramid/security.py | 13 +++++++------ tests/test_security.py | 34 +++++++--------------------------- 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 4881d94a6..9088a9746 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -60,7 +60,7 @@ def remember(request, userid, **kw): on this request's response. These headers are suitable for 'remembering' a set of credentials implied by the data passed as ``userid`` and ``*kw`` using the - current :term:`authentication policy`. Common usage might look + current :term:`security 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): @@ -73,10 +73,10 @@ def remember(request, userid, **kw): response.headerlist.extend(headers) return response - If no :term:`authentication policy` is in use, this function will + If no :term:`security 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. + the effective security policy. .. versionchanged:: 1.6 Deprecated the ``principal`` argument in favor of ``userid`` to clarify @@ -85,7 +85,7 @@ def remember(request, userid, **kw): .. versionchanged:: 1.10 Removed the deprecated ``principal`` argument. """ - policy = _get_authentication_policy(request) + policy = _get_security_policy(request) if policy is None: return [] return policy.remember(request, userid, **kw) @@ -107,10 +107,10 @@ def forget(request): response.headerlist.extend(headers) return response - If no :term:`authentication policy` is in use, this function will + If no :term:`security policy` is in use, this function will always return an empty sequence. """ - policy = _get_authentication_policy(request) + policy = _get_security_policy(request) if policy is None: return [] return policy.forget(request) @@ -132,6 +132,7 @@ def principals_allowed_by_permission(context, permission): required machinery for this function; those will cause a :exc:`NotImplementedError` exception to be raised when this function is invoked. + """ reg = get_current_registry() policy = reg.queryUtility(IAuthorizationPolicy) diff --git a/tests/test_security.py b/tests/test_security.py index 40b5cd061..fae9db76f 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -187,32 +187,22 @@ class TestRemember(unittest.TestCase): return remember(*arg, **kwarg) - def test_no_authentication_policy(self): + def test_no_security_policy(self): request = _makeRequest() result = self._callFUT(request, 'me') self.assertEqual(result, []) - def test_with_authentication_policy(self): + def test_with_security_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') + _registerSecurityPolicy(registry, 'yo') result = self._callFUT(request, 'me') self.assertEqual(result, [('X-Pyramid-Test', 'me')]) def test_with_missing_arg(self): request = _makeRequest() registry = request.registry - _registerAuthenticationPolicy(registry, 'yo') + _registerSecurityPolicy(registry, 'yo') self.assertRaises(TypeError, lambda: self._callFUT(request)) @@ -228,24 +218,14 @@ class TestForget(unittest.TestCase): return forget(*arg) - def test_no_authentication_policy(self): + def test_no_security_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() + def test_with_security_policy(self): request = _makeRequest() - del request.registry - _registerAuthenticationPolicy(registry, 'yo') + _registerSecurityPolicy(request.registry, 'yo') result = self._callFUT(request) self.assertEqual(result, [('X-Pyramid-Test', 'logout')]) -- cgit v1.2.3 From 94a16a7ddd7e9d313e1b447bf478bc51c053e58a Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 9 Mar 2019 12:29:23 -0800 Subject: Implement new dummy security policy. --- src/pyramid/config/testing.py | 15 ++++--------- src/pyramid/testing.py | 40 +++++++--------------------------- tests/test_config/test_testing.py | 24 +++++++-------------- tests/test_config/test_views.py | 29 +++++++++++-------------- tests/test_testing.py | 45 +++++---------------------------------- 5 files changed, 37 insertions(+), 116 deletions(-) diff --git a/src/pyramid/config/testing.py b/src/pyramid/config/testing.py index 9c998840a..21c622656 100644 --- a/src/pyramid/config/testing.py +++ b/src/pyramid/config/testing.py @@ -1,11 +1,6 @@ from zope.interface import Interface -from pyramid.interfaces import ( - ITraverser, - IAuthorizationPolicy, - IAuthenticationPolicy, - IRendererFactory, -) +from pyramid.interfaces import ITraverser, ISecurityPolicy, IRendererFactory from pyramid.renderers import RendererHelper @@ -18,8 +13,7 @@ class TestingConfiguratorMixin(object): # testing API def testing_securitypolicy( self, - userid=None, - groupids=(), + identity=None, permissive=True, remember_result=None, forget_result=None, @@ -69,10 +63,9 @@ class TestingConfiguratorMixin(object): from pyramid.testing import DummySecurityPolicy policy = DummySecurityPolicy( - userid, groupids, permissive, remember_result, forget_result + identity, permissive, remember_result, forget_result ) - self.registry.registerUtility(policy, IAuthorizationPolicy) - self.registry.registerUtility(policy, IAuthenticationPolicy) + self.registry.registerUtility(policy, ISecurityPolicy) return policy def testing_resources(self, resources): diff --git a/src/pyramid/testing.py b/src/pyramid/testing.py index 90a49c04a..4bf6d281f 100644 --- a/src/pyramid/testing.py +++ b/src/pyramid/testing.py @@ -14,12 +14,7 @@ from pyramid.path import caller_package from pyramid.response import _get_response_factory from pyramid.registry import Registry -from pyramid.security import ( - Authenticated, - Everyone, - SecurityAPIMixin, - AuthenticationAPIMixin, -) +from pyramid.security import SecurityAPIMixin, AuthenticationAPIMixin from pyramid.threadlocal import get_current_registry, manager @@ -43,18 +38,16 @@ class DummyRootFactory(object): class DummySecurityPolicy(object): - """ A standin for both an IAuthentication and IAuthorization policy """ + """ A standin for a security policy""" def __init__( self, - userid=None, - groupids=(), + identity=None, permissive=True, remember_result=None, forget_result=None, ): - self.userid = userid - self.groupids = groupids + self.identity = identity self.permissive = permissive if remember_result is None: remember_result = [] @@ -63,19 +56,11 @@ class DummySecurityPolicy(object): self.remember_result = remember_result self.forget_result = forget_result - def authenticated_userid(self, request): - return self.userid + def identify(self, request): + return self.identity - def unauthenticated_userid(self, request): - return self.userid - - def effective_principals(self, request): - effective_principals = [Everyone] - if self.userid: - effective_principals.append(Authenticated) - effective_principals.append(self.userid) - effective_principals.extend(self.groupids) - return effective_principals + def permits(self, request, context, identity, permission): + return self.permissive def remember(self, request, userid, **kw): self.remembered = userid @@ -85,15 +70,6 @@ class DummySecurityPolicy(object): self.forgotten = True return self.forget_result - def permits(self, context, principals, permission): - return self.permissive - - def principals_allowed_by_permission(self, context, permission): - if self.permissive: - return self.effective_principals(None) - else: - return [] - class DummyTemplateRenderer(object): """ diff --git a/tests/test_config/test_testing.py b/tests/test_config/test_testing.py index 822eeac8f..500aedeae 100644 --- a/tests/test_config/test_testing.py +++ b/tests/test_config/test_testing.py @@ -17,28 +17,20 @@ class TestingConfiguratorMixinTests(unittest.TestCase): from pyramid.testing import DummySecurityPolicy config = self._makeOne(autocommit=True) - config.testing_securitypolicy( - 'user', ('group1', 'group2'), permissive=False - ) - from pyramid.interfaces import IAuthenticationPolicy - from pyramid.interfaces import IAuthorizationPolicy + config.testing_securitypolicy('user', permissive=False) + from pyramid.interfaces import ISecurityPolicy - ut = config.registry.getUtility(IAuthenticationPolicy) - self.assertTrue(isinstance(ut, DummySecurityPolicy)) - ut = config.registry.getUtility(IAuthorizationPolicy) - self.assertEqual(ut.userid, 'user') - self.assertEqual(ut.groupids, ('group1', 'group2')) - self.assertEqual(ut.permissive, False) + policy = config.registry.getUtility(ISecurityPolicy) + self.assertTrue(isinstance(policy, DummySecurityPolicy)) + self.assertEqual(policy.identity, 'user') + self.assertEqual(policy.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, + 'user', permissive=False, remember_result=True ) request = DummyRequest() request.registry = config.registry @@ -51,7 +43,7 @@ class TestingConfiguratorMixinTests(unittest.TestCase): config = self._makeOne(autocommit=True) pol = config.testing_securitypolicy( - 'user', ('group1', 'group2'), permissive=False, forget_result=True + 'user', permissive=False, forget_result=True ) request = DummyRequest() request.registry = config.registry diff --git a/tests/test_config/test_views.py b/tests/test_config/test_views.py index 685b81a0f..28b7a9fb1 100644 --- a/tests/test_config/test_views.py +++ b/tests/test_config/test_views.py @@ -2059,22 +2059,19 @@ class TestViewsConfigurationMixin(unittest.TestCase): outerself = self class DummyPolicy(object): - def effective_principals(self, r): + def identify(self, r): outerself.assertEqual(r, request) - return ['abc'] + return 123 - def permits(self, context, principals, permission): + def permits(self, r, context, identity, permission): + outerself.assertEqual(r, request) outerself.assertEqual(context, None) - outerself.assertEqual(principals, ['abc']) + outerself.assertEqual(identity, 123) outerself.assertEqual(permission, 'view') return True policy = DummyPolicy() - config = self._makeOne( - authorization_policy=policy, - authentication_policy=policy, - autocommit=True, - ) + config = self._makeOne(security_policy=policy, autocommit=True) config.add_view(view=view1, permission='view', renderer=null_renderer) view = self._getViewCallable(config) request = self._makeRequest(config) @@ -2087,22 +2084,20 @@ class TestViewsConfigurationMixin(unittest.TestCase): outerself = self class DummyPolicy(object): - def effective_principals(self, r): + def identify(self, r): outerself.assertEqual(r, request) - return ['abc'] + return 123 - def permits(self, context, principals, permission): + def permits(self, r, context, identity, permission): + outerself.assertEqual(r, request) outerself.assertEqual(context, None) - outerself.assertEqual(principals, ['abc']) + outerself.assertEqual(identity, 123) outerself.assertEqual(permission, 'view') return True policy = DummyPolicy() config = self._makeOne( - authorization_policy=policy, - authentication_policy=policy, - default_permission='view', - autocommit=True, + security_policy=policy, default_permission='view', autocommit=True ) config.add_view(view=view1, renderer=null_renderer) view = self._getViewCallable(config) diff --git a/tests/test_testing.py b/tests/test_testing.py index 5b3ad0f22..874d9f11b 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -23,52 +23,17 @@ class TestDummySecurityPolicy(unittest.TestCase): return DummySecurityPolicy - def _makeOne(self, userid=None, groupids=(), permissive=True): + def _makeOne(self, identity=None, permissive=True): klass = self._getTargetClass() - return klass(userid, groupids, permissive) + return klass(identity, permissive) - def test_authenticated_userid(self): + def test_identify(self): policy = self._makeOne('user') - self.assertEqual(policy.authenticated_userid(None), 'user') - - def test_unauthenticated_userid(self): - policy = self._makeOne('user') - self.assertEqual(policy.unauthenticated_userid(None), 'user') - - def test_effective_principals_userid(self): - policy = self._makeOne('user', ('group1',)) - from pyramid.security import Everyone - from pyramid.security import Authenticated - - self.assertEqual( - policy.effective_principals(None), - [Everyone, Authenticated, 'user', 'group1'], - ) - - def test_effective_principals_nouserid(self): - policy = self._makeOne() - from pyramid.security import Everyone - - self.assertEqual(policy.effective_principals(None), [Everyone]) + self.assertEqual(policy.identify(None), 'user') def test_permits(self): policy = self._makeOne() - self.assertEqual(policy.permits(None, None, None), True) - - def test_principals_allowed_by_permission(self): - policy = self._makeOne('user', ('group1',)) - from pyramid.security import Everyone - from pyramid.security import Authenticated - - result = policy.principals_allowed_by_permission(None, None) - self.assertEqual(result, [Everyone, Authenticated, 'user', 'group1']) - - def test_principals_allowed_by_permission_not_permissive(self): - policy = self._makeOne('user', ('group1',)) - policy.permissive = False - - result = policy.principals_allowed_by_permission(None, None) - self.assertEqual(result, []) + self.assertEqual(policy.permits(None, None, None, None), True) def test_forget(self): policy = self._makeOne() -- cgit v1.2.3 From 8a1f8d4acc77cd9fe57405384b5c20e9fa3fb078 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 9 Mar 2019 13:27:13 -0800 Subject: Get integration tests working again. --- src/pyramid/config/security.py | 2 +- tests/pkgs/defpermbugapp/__init__.py | 4 ++-- tests/pkgs/forbiddenapp/__init__.py | 4 ++-- tests/pkgs/staticpermapp/__init__.py | 4 ++-- tests/test_integration.py | 8 +++++--- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/pyramid/config/security.py b/src/pyramid/config/security.py index 42554db97..37dfeac3e 100644 --- a/src/pyramid/config/security.py +++ b/src/pyramid/config/security.py @@ -49,7 +49,7 @@ class SecurityConfiguratorMixin(object): ) intr['policy'] = policy # authentication policy used by view config (phase 3) - self.action(IAuthenticationPolicy, register, introspectables=(intr,)) + self.action(ISecurityPolicy, register, introspectables=(intr,)) def _set_security_policy(self, policy): policy = self.maybe_dotted(policy) diff --git a/tests/pkgs/defpermbugapp/__init__.py b/tests/pkgs/defpermbugapp/__init__.py index 81897e86a..af78404ae 100644 --- a/tests/pkgs/defpermbugapp/__init__.py +++ b/tests/pkgs/defpermbugapp/__init__.py @@ -25,6 +25,6 @@ def includeme(config): authn_policy = AuthTktAuthenticationPolicy('seekt1t', hashalg='sha512') authz_policy = ACLAuthorizationPolicy() config.scan('tests.pkgs.defpermbugapp') - config._set_authentication_policy(authn_policy) - config._set_authorization_policy(authz_policy) + config.set_authentication_policy(authn_policy) + config.set_authorization_policy(authz_policy) config.set_default_permission('private') diff --git a/tests/pkgs/forbiddenapp/__init__.py b/tests/pkgs/forbiddenapp/__init__.py index 31ea4dd52..79670dd32 100644 --- a/tests/pkgs/forbiddenapp/__init__.py +++ b/tests/pkgs/forbiddenapp/__init__.py @@ -22,7 +22,7 @@ def includeme(config): authn_policy = AuthTktAuthenticationPolicy('seekr1t', hashalg='sha512') authz_policy = ACLAuthorizationPolicy() - config._set_authentication_policy(authn_policy) - config._set_authorization_policy(authz_policy) + config.set_authentication_policy(authn_policy) + config.set_authorization_policy(authz_policy) config.add_view(x_view, name='x', permission='private') config.add_view(forbidden_view, context=HTTPForbidden) diff --git a/tests/pkgs/staticpermapp/__init__.py b/tests/pkgs/staticpermapp/__init__.py index ffc87d39a..a12eac2d3 100644 --- a/tests/pkgs/staticpermapp/__init__.py +++ b/tests/pkgs/staticpermapp/__init__.py @@ -18,8 +18,8 @@ def includeme(config): authn_policy = RemoteUserAuthenticationPolicy() authz_policy = ACLAuthorizationPolicy() - config._set_authentication_policy(authn_policy) - config._set_authorization_policy(authz_policy) + config.set_authentication_policy(authn_policy) + config.set_authorization_policy(authz_policy) config.add_static_view('allowed', 'tests:fixtures/static/') config.add_static_view( 'protected', 'tests:fixtures/static/', permission='view' diff --git a/tests/test_integration.py b/tests/test_integration.py index e6dccbb5b..72465dc93 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -581,10 +581,12 @@ class TestConflictApp(unittest.TestCase): def test_overridden_authorization_policy(self): config = self._makeConfig() config.include(self.package) - from pyramid.testing import DummySecurityPolicy - config.set_authorization_policy(DummySecurityPolicy('fred')) - config.set_authentication_policy(DummySecurityPolicy(permissive=True)) + class DummySecurityPolicy: + def permits(self, context, principals, permission): + return True + + config.set_authorization_policy(DummySecurityPolicy()) app = config.make_wsgi_app() self.testapp = TestApp(app) res = self.testapp.get('/protected', status=200) -- cgit v1.2.3 From 282a59b05704c10307a5def54fe3531cfd49dcb7 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 17 Mar 2019 10:52:44 -0700 Subject: Clean up configurator methods. --- src/pyramid/config/security.py | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/pyramid/config/security.py b/src/pyramid/config/security.py index 37dfeac3e..ac7dcef43 100644 --- a/src/pyramid/config/security.py +++ b/src/pyramid/config/security.py @@ -39,8 +39,9 @@ class SecurityConfiguratorMixin(object): """ def register(): - self._set_security_policy(policy) + self.registry.registerUtility(policy, ISecurityPolicy) + policy = self.maybe_dotted(policy) intr = self.introspectable( 'security policy', None, @@ -51,10 +52,6 @@ class SecurityConfiguratorMixin(object): # authentication policy used by view config (phase 3) self.action(ISecurityPolicy, register, introspectables=(intr,)) - def _set_security_policy(self, policy): - policy = self.maybe_dotted(policy) - self.registry.registerUtility(policy, ISecurityPolicy) - @action_method def set_authentication_policy(self, policy): """ Override the :app:`Pyramid` :term:`authentication policy` in the @@ -71,15 +68,22 @@ class SecurityConfiguratorMixin(object): """ def register(): - self._set_authentication_policy(policy) + self.registry.registerUtility(policy, IAuthenticationPolicy) if self.registry.queryUtility(IAuthorizationPolicy) is None: raise ConfigurationError( 'Cannot configure an authentication policy without ' 'also configuring an authorization policy ' '(use the set_authorization_policy method)' ) - self._set_legacy_policy() + if self.registry.queryUtility(ISecurityPolicy) is not None: + raise ConfigurationError( + 'Cannot configure an authentication and authorization' + 'policy with a configured security policy.' + ) + security_policy = LegacySecurityPolicy() + self.registry.registerUtility(security_policy, ISecurityPolicy) + policy = self.maybe_dotted(policy) intr = self.introspectable( 'authentication policy', None, @@ -95,19 +99,6 @@ class SecurityConfiguratorMixin(object): introspectables=(intr,), ) - def _set_authentication_policy(self, policy): - policy = self.maybe_dotted(policy) - self.registry.registerUtility(policy, IAuthenticationPolicy) - - def _set_legacy_policy(self): - if self.registry.queryUtility(ISecurityPolicy) is not None: - raise ConfigurationError( - 'Cannot configure an authentication and authorization policy ' - 'with a configured security policy.' - ) - policy = LegacySecurityPolicy() - self.registry.registerUtility(policy, ISecurityPolicy) - @action_method def set_authorization_policy(self, policy): """ Override the :app:`Pyramid` :term:`authorization policy` in the @@ -123,7 +114,7 @@ class SecurityConfiguratorMixin(object): """ def register(): - self._set_authorization_policy(policy) + self.registry.registerUtility(policy, IAuthorizationPolicy) def ensure(): if self.autocommit: @@ -135,6 +126,7 @@ class SecurityConfiguratorMixin(object): '(use the set_authorization_policy method)' ) + policy = self.maybe_dotted(policy) intr = self.introspectable( 'authorization policy', None, @@ -152,10 +144,6 @@ class SecurityConfiguratorMixin(object): ) self.action(None, ensure) - def _set_authorization_policy(self, policy): - policy = self.maybe_dotted(policy) - self.registry.registerUtility(policy, IAuthorizationPolicy) - @action_method def set_default_permission(self, permission): """ -- cgit v1.2.3 From 31998bcdd0396316c1a0fdeb50bee59e4b9e14ed Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 30 Mar 2019 10:08:31 -0700 Subject: Implement pyramid.security.ACLHelper Mostly a lift-and-shift of the code in ACLAuthorizationPolicy. --- src/pyramid/authorization.py | 79 ++----------- src/pyramid/security.py | 145 +++++++++++++++++++++-- tests/test_security.py | 275 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 415 insertions(+), 84 deletions(-) diff --git a/src/pyramid/authorization.py b/src/pyramid/authorization.py index 6056a8d25..19b96e3d1 100644 --- a/src/pyramid/authorization.py +++ b/src/pyramid/authorization.py @@ -2,11 +2,7 @@ from zope.interface import implementer from pyramid.interfaces import IAuthorizationPolicy -from pyramid.location import lineage - -from pyramid.security import ACLAllowed, ACLDenied, Allow, Deny, Everyone - -from pyramid.util import is_nonstr_iter +from pyramid.security import ACLHelper @implementer(IAuthorizationPolicy) @@ -61,80 +57,21 @@ class ACLAuthorizationPolicy(object): :class:`pyramid.interfaces.IAuthorizationPolicy` interface. """ + def __init__(self): + self.helper = ACLHelper() + def permits(self, context, principals, permission): """ Return an instance of :class:`pyramid.security.ACLAllowed` instance if the policy permits access, return an instance of :class:`pyramid.security.ACLDenied` if not.""" - - acl = '' - - for location in lineage(context): - try: - acl = location.__acl__ - except AttributeError: - continue - - if acl and callable(acl): - acl = acl() - - for ace in acl: - ace_action, ace_principal, ace_permissions = ace - if ace_principal in principals: - if not is_nonstr_iter(ace_permissions): - ace_permissions = [ace_permissions] - if permission in ace_permissions: - if ace_action == Allow: - return ACLAllowed( - ace, acl, permission, principals, location - ) - else: - return ACLDenied( - ace, acl, permission, principals, location - ) - - # default deny (if no ACL in lineage at all, or if none of the - # principals were mentioned in any ACE we found) - return ACLDenied( - '', acl, permission, principals, context - ) + return self.helper.permits(context, principals, permission) def principals_allowed_by_permission(self, context, permission): """ Return the set of principals explicitly granted the permission named ``permission`` according to the ACL directly attached to the ``context`` as well as inherited ACLs based on the :term:`lineage`.""" - allowed = set() - - for location in reversed(list(lineage(context))): - # NB: we're walking *up* the object graph from the root - try: - acl = location.__acl__ - except AttributeError: - continue - - allowed_here = set() - denied_here = set() - - if acl and callable(acl): - acl = acl() - - for ace_action, ace_principal, ace_permissions in acl: - if not is_nonstr_iter(ace_permissions): - ace_permissions = [ace_permissions] - if (ace_action == Allow) and (permission in ace_permissions): - if ace_principal not in denied_here: - allowed_here.add(ace_principal) - if (ace_action == Deny) and (permission in ace_permissions): - denied_here.add(ace_principal) - if ace_principal == Everyone: - # clear the entire allowed set, as we've hit a - # deny of Everyone ala (Deny, Everyone, ALL) - allowed = set() - break - elif ace_principal in allowed: - allowed.remove(ace_principal) - - allowed.update(allowed_here) - - return allowed + return self.helper.principals_allowed_by_permission( + context, permission + ) diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 9088a9746..bfd505a98 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -9,6 +9,10 @@ from pyramid.interfaces import ( IViewClassifier, ) +from pyramid.location import lineage + +from pyramid.util import is_nonstr_iter + from pyramid.threadlocal import get_current_registry Everyone = 'system.Everyone' @@ -36,22 +40,12 @@ DENY_ALL = (Deny, Everyone, ALL_PERMISSIONS) NO_PERMISSION_REQUIRED = '__no_permission_required__' -def _get_registry(request): - try: - reg = request.registry - except AttributeError: - reg = get_current_registry() # b/c - return reg - - def _get_security_policy(request): - registry = _get_registry(request) - return registry.queryUtility(ISecurityPolicy) + return request.registry.queryUtility(ISecurityPolicy) def _get_authentication_policy(request): - registry = _get_registry(request) - return registry.queryUtility(IAuthenticationPolicy) + return request.registry.queryUtility(IAuthenticationPolicy) def remember(request, userid, **kw): @@ -154,7 +148,7 @@ def view_execution_permitted(context, request, name=''): An exception is raised if no view is found. """ - reg = _get_registry(request) + reg = request.registry provides = [IViewClassifier] + [providedBy(x) for x in (request, context)] # XXX not sure what to do here about using _find_views or analogue; # for now let's just keep it as-is @@ -421,3 +415,128 @@ class LegacySecurityPolicy: authz = self._get_authz_policy(request) principals = authn.effective_principals(request) return authz.permits(context, principals, permission) + + +class ACLHelper: + """ A helper for use with constructing a :term:`security policy` which + consults an :term:`ACL` object attached to a :term:`context` to determine + authorization information about a :term:`principal` or multiple principals. + If the context is part of a :term:`lineage`, the context's parents are + consulted for ACL information too. + + """ + + def permits(self, context, principals, permission): + """ Return an instance of :class:`pyramid.security.ACLAllowed` if the + ACL allows access a user with the given principals, return an instance + of :class:`pyramid.security.ACLDenied` if not. + + When checking if principals are allowed, the security policy consults + the ``context`` for an ACL first. If no ACL exists on the context, or + one does exist but the ACL does not explicitly allow or deny access for + any of the effective principals, consult the context's parent ACL, and + so on, until the lineage is exhausted or we determine that the policy + permits or denies. + + During this processing, if any :data:`pyramid.security.Deny` + ACE is found matching any principal in ``principals``, stop + processing by returning an + :class:`pyramid.security.ACLDenied` instance (equals + ``False``) immediately. If any + :data:`pyramid.security.Allow` ACE is found matching any + principal, stop processing by returning an + :class:`pyramid.security.ACLAllowed` instance (equals + ``True``) immediately. If we exhaust the context's + :term:`lineage`, and no ACE has explicitly permitted or denied + access, return an instance of + :class:`pyramid.security.ACLDenied` (equals ``False``). + + """ + acl = '' + + for location in lineage(context): + try: + acl = location.__acl__ + except AttributeError: + continue + + if acl and callable(acl): + acl = acl() + + for ace in acl: + ace_action, ace_principal, ace_permissions = ace + if ace_principal in principals: + if not is_nonstr_iter(ace_permissions): + ace_permissions = [ace_permissions] + if permission in ace_permissions: + if ace_action == Allow: + return ACLAllowed( + ace, acl, permission, principals, location + ) + else: + return ACLDenied( + ace, acl, permission, principals, location + ) + + # default deny (if no ACL in lineage at all, or if none of the + # principals were mentioned in any ACE we found) + return ACLDenied( + '', acl, permission, principals, context + ) + + def principals_allowed_by_permission(self, context, permission): + """ Return the set of principals explicitly granted the permission + named ``permission`` according to the ACL directly attached to the + ``context`` as well as inherited ACLs based on the :term:`lineage`. + + When computing principals allowed by a permission, we compute the set + of principals that are explicitly granted the ``permission`` in the + provided ``context``. We do this by walking 'up' the object graph + *from the root* to the context. During this walking process, if we + find an explicit :data:`pyramid.security.Allow` ACE for a principal + that matches the ``permission``, the principal is included in the allow + list. However, if later in the walking process that principal is + mentioned in any :data:`pyramid.security.Deny` ACE for the permission, + the principal is removed from the allow list. If a + :data:`pyramid.security.Deny` to the principal + :data:`pyramid.security.Everyone` is encountered during the walking + process that matches the ``permission``, the allow list is cleared for + all principals encountered in previous ACLs. The walking process ends + after we've processed the any ACL directly attached to ``context``; a + set of principals is returned. + + """ + allowed = set() + + for location in reversed(list(lineage(context))): + # NB: we're walking *up* the object graph from the root + try: + acl = location.__acl__ + except AttributeError: + continue + + allowed_here = set() + denied_here = set() + + if acl and callable(acl): + acl = acl() + + for ace_action, ace_principal, ace_permissions in acl: + if not is_nonstr_iter(ace_permissions): + ace_permissions = [ace_permissions] + if (ace_action == Allow) and (permission in ace_permissions): + if ace_principal not in denied_here: + allowed_here.add(ace_principal) + if (ace_action == Deny) and (permission in ace_permissions): + denied_here.add(ace_principal) + if ace_principal == Everyone: + # clear the entire allowed set, as we've hit a + # deny of Everyone ala (Deny, Everyone, ALL) + allowed = set() + break + elif ace_principal in allowed: + allowed.remove(ace_principal) + + allowed.update(allowed_here) + + return allowed diff --git a/tests/test_security.py b/tests/test_security.py index fae9db76f..b91aa7682 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -611,3 +611,278 @@ def _makeRequest(): request.registry = Registry() request.context = object() return request + + +class TestACLHelper(unittest.TestCase): + def test_no_acl(self): + from pyramid.security import ACLHelper + + context = DummyContext() + helper = ACLHelper() + result = helper.permits(context, ['foo'], 'permission') + self.assertEqual(result, False) + self.assertEqual(result.ace, '') + self.assertEqual( + result.acl, '' + ) + self.assertEqual(result.permission, 'permission') + self.assertEqual(result.principals, ['foo']) + self.assertEqual(result.context, context) + + def test_acl(self): + from pyramid.security import ACLHelper + from pyramid.security import Deny + from pyramid.security import Allow + from pyramid.security import Everyone + from pyramid.security import Authenticated + from pyramid.security import ALL_PERMISSIONS + from pyramid.security import DENY_ALL + + helper = ACLHelper() + root = DummyContext() + community = DummyContext(__name__='community', __parent__=root) + blog = DummyContext(__name__='blog', __parent__=community) + root.__acl__ = [(Allow, Authenticated, VIEW)] + community.__acl__ = [ + (Allow, 'fred', ALL_PERMISSIONS), + (Allow, 'wilma', VIEW), + DENY_ALL, + ] + blog.__acl__ = [ + (Allow, 'barney', MEMBER_PERMS), + (Allow, 'wilma', VIEW), + ] + + result = helper.permits( + blog, [Everyone, Authenticated, 'wilma'], 'view' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, blog) + self.assertEqual(result.ace, (Allow, 'wilma', VIEW)) + self.assertEqual(result.acl, blog.__acl__) + + result = helper.permits( + blog, [Everyone, Authenticated, 'wilma'], 'delete' + ) + self.assertEqual(result, False) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) + self.assertEqual(result.acl, community.__acl__) + + result = helper.permits( + blog, [Everyone, Authenticated, 'fred'], 'view' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Allow, 'fred', ALL_PERMISSIONS)) + result = helper.permits( + blog, [Everyone, Authenticated, 'fred'], 'doesntevenexistyet' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Allow, 'fred', ALL_PERMISSIONS)) + self.assertEqual(result.acl, community.__acl__) + + result = helper.permits( + blog, [Everyone, Authenticated, 'barney'], 'view' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, blog) + self.assertEqual(result.ace, (Allow, 'barney', MEMBER_PERMS)) + result = helper.permits( + blog, [Everyone, Authenticated, 'barney'], 'administer' + ) + self.assertEqual(result, False) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) + self.assertEqual(result.acl, community.__acl__) + + result = helper.permits( + root, [Everyone, Authenticated, 'someguy'], 'view' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, root) + self.assertEqual(result.ace, (Allow, Authenticated, VIEW)) + result = helper.permits( + blog, [Everyone, Authenticated, 'someguy'], 'view' + ) + self.assertEqual(result, False) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) + self.assertEqual(result.acl, community.__acl__) + + result = helper.permits(root, [Everyone], 'view') + self.assertEqual(result, False) + self.assertEqual(result.context, root) + self.assertEqual(result.ace, '') + self.assertEqual(result.acl, root.__acl__) + + context = DummyContext() + result = helper.permits(context, [Everyone], 'view') + self.assertEqual(result, False) + self.assertEqual(result.ace, '') + self.assertEqual( + result.acl, '' + ) + + def test_string_permissions_in_acl(self): + from pyramid.security import ACLHelper + from pyramid.security import Allow + + helper = ACLHelper() + root = DummyContext() + root.__acl__ = [(Allow, 'wilma', 'view_stuff')] + + result = helper.permits(root, ['wilma'], 'view') + # would be True if matching against 'view_stuff' instead of against + # ['view_stuff'] + self.assertEqual(result, False) + + def test_callable_acl(self): + from pyramid.security import ACLHelper + from pyramid.security import Allow + + helper = ACLHelper() + context = DummyContext() + fn = lambda self: [(Allow, 'bob', 'read')] + context.__acl__ = fn.__get__(context, context.__class__) + result = helper.permits(context, ['bob'], 'read') + self.assertTrue(result) + + def test_principals_allowed_by_permission_direct(self): + from pyramid.security import ACLHelper + from pyramid.security import Allow + from pyramid.security import DENY_ALL + + helper = ACLHelper() + context = DummyContext() + acl = [ + (Allow, 'chrism', ('read', 'write')), + DENY_ALL, + (Allow, 'other', 'read'), + ] + context.__acl__ = acl + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, ['chrism']) + + def test_principals_allowed_by_permission_callable_acl(self): + from pyramid.security import ACLHelper + from pyramid.security import Allow + from pyramid.security import DENY_ALL + + helper = ACLHelper() + context = DummyContext() + acl = lambda: [ + (Allow, 'chrism', ('read', 'write')), + DENY_ALL, + (Allow, 'other', 'read'), + ] + context.__acl__ = acl + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, ['chrism']) + + def test_principals_allowed_by_permission_string_permission(self): + from pyramid.security import ACLHelper + from pyramid.security import Allow + + helper = ACLHelper() + context = DummyContext() + acl = [(Allow, 'chrism', 'read_it')] + context.__acl__ = acl + result = helper.principals_allowed_by_permission(context, 'read') + # would be ['chrism'] if 'read' were compared against 'read_it' instead + # of against ['read_it'] + self.assertEqual(list(result), []) + + def test_principals_allowed_by_permission(self): + from pyramid.security import ACLHelper + from pyramid.security import Allow + from pyramid.security import Deny + from pyramid.security import DENY_ALL + from pyramid.security import ALL_PERMISSIONS + + helper = ACLHelper() + root = DummyContext(__name__='', __parent__=None) + community = DummyContext(__name__='community', __parent__=root) + blog = DummyContext(__name__='blog', __parent__=community) + root.__acl__ = [ + (Allow, 'chrism', ('read', 'write')), + (Allow, 'other', ('read',)), + (Allow, 'jim', ALL_PERMISSIONS), + ] + community.__acl__ = [ + (Deny, 'flooz', 'read'), + (Allow, 'flooz', 'read'), + (Allow, 'mork', 'read'), + (Deny, 'jim', 'read'), + (Allow, 'someguy', 'manage'), + ] + blog.__acl__ = [(Allow, 'fred', 'read'), DENY_ALL] + + result = sorted(helper.principals_allowed_by_permission(blog, 'read')) + self.assertEqual(result, ['fred']) + result = sorted( + helper.principals_allowed_by_permission(community, 'read') + ) + self.assertEqual(result, ['chrism', 'mork', 'other']) + result = sorted( + helper.principals_allowed_by_permission(community, 'read') + ) + result = sorted(helper.principals_allowed_by_permission(root, 'read')) + self.assertEqual(result, ['chrism', 'jim', 'other']) + + def test_principals_allowed_by_permission_no_acls(self): + from pyramid.security import ACLHelper + + helper = ACLHelper() + context = DummyContext() + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, []) + + def test_principals_allowed_by_permission_deny_not_permission_in_acl(self): + from pyramid.security import ACLHelper + from pyramid.security import Deny + from pyramid.security import Everyone + + helper = ACLHelper() + context = DummyContext() + acl = [(Deny, Everyone, 'write')] + context.__acl__ = acl + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, []) + + def test_principals_allowed_by_permission_deny_permission_in_acl(self): + from pyramid.security import ACLHelper + from pyramid.security import Deny + from pyramid.security import Everyone + + helper = ACLHelper() + context = DummyContext() + acl = [(Deny, Everyone, 'read')] + context.__acl__ = acl + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, []) + + +VIEW = 'view' +EDIT = 'edit' +CREATE = 'create' +DELETE = 'delete' +MODERATE = 'moderate' +ADMINISTER = 'administer' +COMMENT = 'comment' + +GUEST_PERMS = (VIEW, COMMENT) +MEMBER_PERMS = GUEST_PERMS + (EDIT, CREATE, DELETE) +MODERATOR_PERMS = MEMBER_PERMS + (MODERATE,) +ADMINISTRATOR_PERMS = MODERATOR_PERMS + (ADMINISTER,) -- cgit v1.2.3 From 6aba89d19cc384021864d3b83d53082f56c3f419 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 30 Mar 2019 10:31:17 -0700 Subject: Add SessionAuthenticationHelper. --- src/pyramid/security.py | 31 +++++++++++++++++++++++++++++++ tests/test_security.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/pyramid/security.py b/src/pyramid/security.py index bfd505a98..671cd3569 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -540,3 +540,34 @@ class ACLHelper: allowed.update(allowed_here) return allowed + + +class SessionAuthenticationHelper: + """ A helper for use with a :term:`security policy` which stores user data + in the configured :term:`session`. + + Constructor Arguments + + ``prefix`` + + A prefix used when storing the authentication parameters in the + session. Defaults to 'auth.'. Optional. + + """ + + def __init__(self, prefix='auth.'): + self.userid_key = prefix + 'userid' + + def remember(self, request, userid, **kw): + """ Store a userid in the session.""" + request.session[self.userid_key] = userid + return [] + + def forget(self, request): + """ Remove the stored userid from the session.""" + if self.userid_key in request.session: + del request.session[self.userid_key] + return [] + + def identify(self, request): + return request.session.get(self.userid_key) diff --git a/tests/test_security.py b/tests/test_security.py index b91aa7682..73d8ba6fc 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -886,3 +886,52 @@ GUEST_PERMS = (VIEW, COMMENT) MEMBER_PERMS = GUEST_PERMS + (EDIT, CREATE, DELETE) MODERATOR_PERMS = MEMBER_PERMS + (MODERATE,) ADMINISTRATOR_PERMS = MODERATOR_PERMS + (ADMINISTER,) + + +class TestSessionAuthenticationHelper(unittest.TestCase): + def _makeRequest(self, session=None): + from types import SimpleNamespace + if session is None: + session = dict() + return SimpleNamespace(session=session) + + def _makeOne(self, prefix=''): + from pyramid.security import SessionAuthenticationHelper + + return SessionAuthenticationHelper(prefix=prefix) + + def test_identify(self): + request = self._makeRequest({'userid': 'fred'}) + helper = self._makeOne() + self.assertEqual(helper.identify(request), 'fred') + + def test_identify_with_prefix(self): + request = self._makeRequest({'foo.userid': 'fred'}) + helper = self._makeOne(prefix='foo.') + self.assertEqual(helper.identify(request), 'fred') + + def test_identify_none(self): + request = self._makeRequest() + helper = self._makeOne() + self.assertEqual(helper.identify(request), None) + + def test_remember(self): + request = self._makeRequest() + helper = self._makeOne() + result = helper.remember(request, 'fred') + self.assertEqual(request.session.get('userid'), 'fred') + self.assertEqual(result, []) + + def test_forget(self): + request = self._makeRequest({'userid': 'fred'}) + helper = self._makeOne() + result = helper.forget(request) + self.assertEqual(request.session.get('userid'), None) + self.assertEqual(result, []) + + def test_forget_no_identity(self): + request = self._makeRequest() + helper = self._makeOne() + result = helper.forget(request) + self.assertEqual(request.session.get('userid'), None) + self.assertEqual(result, []) -- cgit v1.2.3 From 3d9c5c534c2200aeebad278466a961895901e617 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 30 Mar 2019 11:04:22 -0700 Subject: Fix formatting. --- tests/test_security.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_security.py b/tests/test_security.py index 73d8ba6fc..dd5be54d7 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -891,6 +891,7 @@ ADMINISTRATOR_PERMS = MODERATOR_PERMS + (ADMINISTER,) class TestSessionAuthenticationHelper(unittest.TestCase): def _makeRequest(self, session=None): from types import SimpleNamespace + if session is None: session = dict() return SimpleNamespace(session=session) -- cgit v1.2.3 From 9f267dd842c5e93336f0392f2809da75a716039a Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 30 Mar 2019 11:09:44 -0700 Subject: Migrate AuthTktCookieHelper to pyramid.security. --- src/pyramid/authentication.py | 444 +----------------- src/pyramid/security.py | 558 ++++++++++++++++++++++- tests/test_authentication.py | 1000 +--------------------------------------- tests/test_security.py | 1008 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1579 insertions(+), 1431 deletions(-) diff --git a/src/pyramid/authentication.py b/src/pyramid/authentication.py index 21cfc0c0e..12f31e5dd 100644 --- a/src/pyramid/authentication.py +++ b/src/pyramid/authentication.py @@ -1,26 +1,23 @@ import binascii -from codecs import utf_8_decode -from codecs import utf_8_encode from collections import namedtuple -import hashlib -import base64 -import re -import time as time_mod -from urllib.parse import quote, unquote -import warnings from zope.interface import implementer -from webob.cookies import CookieProfile - from pyramid.interfaces import IAuthenticationPolicy, IDebugLogger -from pyramid.security import Authenticated, Everyone - -from pyramid.util import strings_differ, bytes_, ascii_, text_ -from pyramid.util import SimpleSerializer - -VALID_TOKEN = re.compile(r"^[A-Za-z][A-Za-z0-9+_-]*$") +from pyramid.security import Authenticated, Everyone, AuthTktCookieHelper + +# bw compat after moving AuthTktHelper and friends to pyramid.security +from pyramid.security import ( # noqa + VALID_TOKEN, + b64encode, + b64decode, + AuthTicket, + BadTicket, + parse_ticket, + calculate_digest, + encode_ip_timestamp, +) class CallbackAuthenticationPolicy(object): @@ -651,421 +648,6 @@ class AuthTktAuthenticationPolicy(CallbackAuthenticationPolicy): return self.cookie.forget(request) -def b64encode(v): - return base64.b64encode(bytes_(v)).strip().replace(b'\n', b'') - - -def b64decode(v): - return base64.b64decode(bytes_(v)) - - -# this class licensed under the MIT license (stolen from Paste) -class AuthTicket(object): - """ - This class represents an authentication token. You must pass in - the shared secret, the userid, and the IP address. Optionally you - can include tokens (a list of strings, representing role names), - 'user_data', which is arbitrary data available for your own use in - later scripts. Lastly, you can override the cookie name and - timestamp. - - Once you provide all the arguments, use .cookie_value() to - generate the appropriate authentication ticket. - - Usage:: - - token = AuthTicket('sharedsecret', 'username', - os.environ['REMOTE_ADDR'], tokens=['admin']) - val = token.cookie_value() - - """ - - def __init__( - self, - secret, - userid, - ip, - tokens=(), - user_data='', - time=None, - cookie_name='auth_tkt', - secure=False, - hashalg='md5', - ): - self.secret = secret - self.userid = userid - self.ip = ip - self.tokens = ','.join(tokens) - self.user_data = user_data - if time is None: - self.time = time_mod.time() - else: - self.time = time - self.cookie_name = cookie_name - self.secure = secure - self.hashalg = hashalg - - def digest(self): - return calculate_digest( - self.ip, - self.time, - self.secret, - self.userid, - self.tokens, - self.user_data, - self.hashalg, - ) - - def cookie_value(self): - v = '%s%08x%s!' % (self.digest(), int(self.time), quote(self.userid)) - if self.tokens: - v += self.tokens + '!' - v += self.user_data - return v - - -# this class licensed under the MIT license (stolen from Paste) -class BadTicket(Exception): - """ - Exception raised when a ticket can't be parsed. If we get far enough to - determine what the expected digest should have been, expected is set. - This should not be shown by default, but can be useful for debugging. - """ - - def __init__(self, msg, expected=None): - self.expected = expected - Exception.__init__(self, msg) - - -# this function licensed under the MIT license (stolen from Paste) -def parse_ticket(secret, ticket, ip, hashalg='md5'): - """ - Parse the ticket, returning (timestamp, userid, tokens, user_data). - - If the ticket cannot be parsed, a ``BadTicket`` exception will be raised - with an explanation. - """ - ticket = text_(ticket).strip('"') - digest_size = hashlib.new(hashalg).digest_size * 2 - digest = ticket[:digest_size] - try: - timestamp = int(ticket[digest_size : digest_size + 8], 16) - except ValueError as e: - raise BadTicket('Timestamp is not a hex integer: %s' % e) - try: - userid, data = ticket[digest_size + 8 :].split('!', 1) - except ValueError: - raise BadTicket('userid is not followed by !') - userid = unquote(userid) - if '!' in data: - tokens, user_data = data.split('!', 1) - else: # pragma: no cover (never generated) - # @@: Is this the right order? - tokens = '' - user_data = data - - expected = calculate_digest( - ip, timestamp, secret, userid, tokens, user_data, hashalg - ) - - # Avoid timing attacks (see - # http://seb.dbzteam.org/crypto/python-oauth-timing-hmac.pdf) - if strings_differ(expected, digest): - raise BadTicket( - 'Digest signature is not correct', expected=(expected, digest) - ) - - tokens = tokens.split(',') - - return (timestamp, userid, tokens, user_data) - - -# this function licensed under the MIT license (stolen from Paste) -def calculate_digest( - ip, timestamp, secret, userid, tokens, user_data, hashalg='md5' -): - secret = bytes_(secret, 'utf-8') - userid = bytes_(userid, 'utf-8') - tokens = bytes_(tokens, 'utf-8') - user_data = bytes_(user_data, 'utf-8') - hash_obj = hashlib.new(hashalg) - - # Check to see if this is an IPv6 address - if ':' in ip: - ip_timestamp = ip + str(int(timestamp)) - ip_timestamp = bytes_(ip_timestamp) - else: - # encode_ip_timestamp not required, left in for backwards compatibility - ip_timestamp = encode_ip_timestamp(ip, timestamp) - - hash_obj.update( - ip_timestamp + secret + userid + b'\0' + tokens + b'\0' + user_data - ) - digest = hash_obj.hexdigest() - hash_obj2 = hashlib.new(hashalg) - hash_obj2.update(bytes_(digest) + secret) - return hash_obj2.hexdigest() - - -# this function licensed under the MIT license (stolen from Paste) -def encode_ip_timestamp(ip, timestamp): - ip_chars = ''.join(map(chr, map(int, ip.split('.')))) - t = int(timestamp) - ts = ( - (t & 0xFF000000) >> 24, - (t & 0xFF0000) >> 16, - (t & 0xFF00) >> 8, - t & 0xFF, - ) - ts_chars = ''.join(map(chr, ts)) - return bytes_(ip_chars + ts_chars) - - -class AuthTktCookieHelper(object): - """ - A helper class for use in third-party authentication policy - implementations. See - :class:`pyramid.authentication.AuthTktAuthenticationPolicy` for the - meanings of the constructor arguments. - """ - - parse_ticket = staticmethod(parse_ticket) # for tests - AuthTicket = AuthTicket # for tests - BadTicket = BadTicket # for tests - now = None # for tests - - userid_type_decoders = { - 'int': int, - 'unicode': lambda x: utf_8_decode(x)[0], # bw compat for old cookies - 'b64unicode': lambda x: utf_8_decode(b64decode(x))[0], - 'b64str': lambda x: b64decode(x), - } - - userid_type_encoders = { - int: ('int', str), - str: ('b64unicode', lambda x: b64encode(utf_8_encode(x)[0])), - bytes: ('b64str', lambda x: b64encode(x)), - } - - def __init__( - self, - secret, - cookie_name='auth_tkt', - secure=False, - include_ip=False, - timeout=None, - reissue_time=None, - max_age=None, - http_only=False, - path="/", - wild_domain=True, - hashalg='md5', - parent_domain=False, - domain=None, - samesite='Lax', - ): - self.cookie_profile = CookieProfile( - cookie_name=cookie_name, - secure=secure, - max_age=max_age, - httponly=http_only, - path=path, - serializer=SimpleSerializer(), - samesite=samesite, - ) - - self.secret = secret - self.cookie_name = cookie_name - self.secure = secure - self.include_ip = include_ip - self.timeout = timeout if timeout is None else int(timeout) - self.reissue_time = ( - reissue_time if reissue_time is None else int(reissue_time) - ) - self.max_age = max_age if max_age is None else int(max_age) - self.wild_domain = wild_domain - self.parent_domain = parent_domain - self.domain = domain - self.hashalg = hashalg - - def _get_cookies(self, request, value, max_age=None): - cur_domain = request.domain - - domains = [] - if self.domain: - domains.append(self.domain) - else: - if self.parent_domain and cur_domain.count('.') > 1: - domains.append('.' + cur_domain.split('.', 1)[1]) - else: - domains.append(None) - domains.append(cur_domain) - if self.wild_domain: - domains.append('.' + cur_domain) - - profile = self.cookie_profile(request) - - kw = {} - kw['domains'] = domains - if max_age is not None: - kw['max_age'] = max_age - - headers = profile.get_headers(value, **kw) - return headers - - def identify(self, request): - """ Return a dictionary with authentication information, or ``None`` - if no valid auth_tkt is attached to ``request``""" - environ = request.environ - cookie = request.cookies.get(self.cookie_name) - - if cookie is None: - return None - - if self.include_ip: - remote_addr = environ['REMOTE_ADDR'] - else: - remote_addr = '0.0.0.0' - - try: - timestamp, userid, tokens, user_data = self.parse_ticket( - self.secret, cookie, remote_addr, self.hashalg - ) - except self.BadTicket: - return None - - now = self.now # service tests - - if now is None: - now = time_mod.time() - - if self.timeout and ((timestamp + self.timeout) < now): - # the auth_tkt data has expired - return None - - userid_typename = 'userid_type:' - user_data_info = user_data.split('|') - for datum in filter(None, user_data_info): - if datum.startswith(userid_typename): - userid_type = datum[len(userid_typename) :] - decoder = self.userid_type_decoders.get(userid_type) - if decoder: - userid = decoder(userid) - - reissue = self.reissue_time is not None - - if reissue and not hasattr(request, '_authtkt_reissued'): - if (now - timestamp) > self.reissue_time: - # See https://github.com/Pylons/pyramid/issues#issue/108 - tokens = list(filter(None, tokens)) - headers = self.remember( - request, userid, max_age=self.max_age, tokens=tokens - ) - - def reissue_authtkt(request, response): - if not hasattr(request, '_authtkt_reissue_revoked'): - for k, v in headers: - response.headerlist.append((k, v)) - - request.add_response_callback(reissue_authtkt) - request._authtkt_reissued = True - - environ['REMOTE_USER_TOKENS'] = tokens - environ['REMOTE_USER_DATA'] = user_data - environ['AUTH_TYPE'] = 'cookie' - - identity = {} - identity['timestamp'] = timestamp - identity['userid'] = userid - identity['tokens'] = tokens - identity['userdata'] = user_data - return identity - - def forget(self, request): - """ Return a set of expires Set-Cookie headers, which will destroy - any existing auth_tkt cookie when attached to a response""" - request._authtkt_reissue_revoked = True - return self._get_cookies(request, None) - - def remember(self, request, userid, max_age=None, tokens=()): - """ Return a set of Set-Cookie headers; when set into a response, - these headers will represent a valid authentication ticket. - - ``max_age`` - The max age of the auth_tkt cookie, in seconds. When this value is - set, the cookie's ``Max-Age`` and ``Expires`` settings will be set, - allowing the auth_tkt cookie to last between browser sessions. If - this value is ``None``, the ``max_age`` value provided to the - helper itself will be used as the ``max_age`` value. Default: - ``None``. - - ``tokens`` - A sequence of strings that will be placed into the auth_tkt tokens - field. Each string in the sequence must be of the Python ``str`` - type and must match the regex ``^[A-Za-z][A-Za-z0-9+_-]*$``. - Tokens are available in the returned identity when an auth_tkt is - found in the request and unpacked. Default: ``()``. - """ - max_age = self.max_age if max_age is None else int(max_age) - - environ = request.environ - - if self.include_ip: - remote_addr = environ['REMOTE_ADDR'] - else: - remote_addr = '0.0.0.0' - - user_data = '' - - encoding_data = self.userid_type_encoders.get(type(userid)) - - if encoding_data: - encoding, encoder = encoding_data - else: - warnings.warn( - "userid is of type {}, and is not supported by the " - "AuthTktAuthenticationPolicy. Explicitly converting to string " - "and storing as base64. Subsequent requests will receive a " - "string as the userid, it will not be decoded back to the " - "type provided.".format(type(userid)), - RuntimeWarning, - ) - encoding, encoder = self.userid_type_encoders.get(str) - userid = str(userid) - - userid = encoder(userid) - user_data = 'userid_type:%s' % encoding - - new_tokens = [] - for token in tokens: - if isinstance(token, str): - try: - token = ascii_(token) - except UnicodeEncodeError: - raise ValueError("Invalid token %r" % (token,)) - if not (isinstance(token, str) and VALID_TOKEN.match(token)): - raise ValueError("Invalid token %r" % (token,)) - new_tokens.append(token) - tokens = tuple(new_tokens) - - if hasattr(request, '_authtkt_reissued'): - request._authtkt_reissue_revoked = True - - ticket = self.AuthTicket( - self.secret, - userid, - remote_addr, - tokens=tokens, - user_data=user_data, - cookie_name=self.cookie_name, - secure=self.secure, - hashalg=self.hashalg, - ) - - cookie_value = ticket.cookie_value() - return self._get_cookies(request, cookie_value, max_age) - - @implementer(IAuthenticationPolicy) class SessionAuthenticationPolicy(CallbackAuthenticationPolicy): """ A :app:`Pyramid` authentication policy which gets its data from the diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 671cd3569..e28ce0f1c 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -1,3 +1,14 @@ +from codecs import utf_8_decode +from codecs import utf_8_encode +import hashlib +import base64 +import time as time_mod +from urllib.parse import quote, unquote +import warnings +import re + +from webob.cookies import CookieProfile + from zope.interface import implementer, providedBy from pyramid.interfaces import ( @@ -11,10 +22,14 @@ from pyramid.interfaces import ( from pyramid.location import lineage -from pyramid.util import is_nonstr_iter +from pyramid.util import is_nonstr_iter, strings_differ, bytes_, ascii_, text_ + +from pyramid.util import SimpleSerializer from pyramid.threadlocal import get_current_registry +VALID_TOKEN = re.compile(r"^[A-Za-z][A-Za-z0-9+_-]*$") + Everyone = 'system.Everyone' Authenticated = 'system.Authenticated' Allow = 'Allow' @@ -571,3 +586,544 @@ class SessionAuthenticationHelper: def identify(self, request): return request.session.get(self.userid_key) + + +def b64encode(v): + return base64.b64encode(bytes_(v)).strip().replace(b'\n', b'') + + +def b64decode(v): + return base64.b64decode(bytes_(v)) + + +# this class licensed under the MIT license (stolen from Paste) +class AuthTicket(object): + """ + This class represents an authentication token. You must pass in + the shared secret, the userid, and the IP address. Optionally you + can include tokens (a list of strings, representing role names), + 'user_data', which is arbitrary data available for your own use in + later scripts. Lastly, you can override the cookie name and + timestamp. + + Once you provide all the arguments, use .cookie_value() to + generate the appropriate authentication ticket. + + Usage:: + + token = AuthTicket('sharedsecret', 'username', + os.environ['REMOTE_ADDR'], tokens=['admin']) + val = token.cookie_value() + + """ + + def __init__( + self, + secret, + userid, + ip, + tokens=(), + user_data='', + time=None, + cookie_name='auth_tkt', + secure=False, + hashalg='md5', + ): + self.secret = secret + self.userid = userid + self.ip = ip + self.tokens = ','.join(tokens) + self.user_data = user_data + if time is None: + self.time = time_mod.time() + else: + self.time = time + self.cookie_name = cookie_name + self.secure = secure + self.hashalg = hashalg + + def digest(self): + return calculate_digest( + self.ip, + self.time, + self.secret, + self.userid, + self.tokens, + self.user_data, + self.hashalg, + ) + + def cookie_value(self): + v = '%s%08x%s!' % (self.digest(), int(self.time), quote(self.userid)) + if self.tokens: + v += self.tokens + '!' + v += self.user_data + return v + + +# this class licensed under the MIT license (stolen from Paste) +class BadTicket(Exception): + """ + Exception raised when a ticket can't be parsed. If we get far enough to + determine what the expected digest should have been, expected is set. + This should not be shown by default, but can be useful for debugging. + """ + + def __init__(self, msg, expected=None): + self.expected = expected + Exception.__init__(self, msg) + + +# this function licensed under the MIT license (stolen from Paste) +def parse_ticket(secret, ticket, ip, hashalg='md5'): + """ + Parse the ticket, returning (timestamp, userid, tokens, user_data). + + If the ticket cannot be parsed, a ``BadTicket`` exception will be raised + with an explanation. + """ + ticket = text_(ticket).strip('"') + digest_size = hashlib.new(hashalg).digest_size * 2 + digest = ticket[:digest_size] + try: + timestamp = int(ticket[digest_size : digest_size + 8], 16) + except ValueError as e: + raise BadTicket('Timestamp is not a hex integer: %s' % e) + try: + userid, data = ticket[digest_size + 8 :].split('!', 1) + except ValueError: + raise BadTicket('userid is not followed by !') + userid = unquote(userid) + if '!' in data: + tokens, user_data = data.split('!', 1) + else: # pragma: no cover (never generated) + # @@: Is this the right order? + tokens = '' + user_data = data + + expected = calculate_digest( + ip, timestamp, secret, userid, tokens, user_data, hashalg + ) + + # Avoid timing attacks (see + # http://seb.dbzteam.org/crypto/python-oauth-timing-hmac.pdf) + if strings_differ(expected, digest): + raise BadTicket( + 'Digest signature is not correct', expected=(expected, digest) + ) + + tokens = tokens.split(',') + + return (timestamp, userid, tokens, user_data) + + +# this function licensed under the MIT license (stolen from Paste) +def calculate_digest( + ip, timestamp, secret, userid, tokens, user_data, hashalg='md5' +): + secret = bytes_(secret, 'utf-8') + userid = bytes_(userid, 'utf-8') + tokens = bytes_(tokens, 'utf-8') + user_data = bytes_(user_data, 'utf-8') + hash_obj = hashlib.new(hashalg) + + # Check to see if this is an IPv6 address + if ':' in ip: + ip_timestamp = ip + str(int(timestamp)) + ip_timestamp = bytes_(ip_timestamp) + else: + # encode_ip_timestamp not required, left in for backwards compatibility + ip_timestamp = encode_ip_timestamp(ip, timestamp) + + hash_obj.update( + ip_timestamp + secret + userid + b'\0' + tokens + b'\0' + user_data + ) + digest = hash_obj.hexdigest() + hash_obj2 = hashlib.new(hashalg) + hash_obj2.update(bytes_(digest) + secret) + return hash_obj2.hexdigest() + + +# this function licensed under the MIT license (stolen from Paste) +def encode_ip_timestamp(ip, timestamp): + ip_chars = ''.join(map(chr, map(int, ip.split('.')))) + t = int(timestamp) + ts = ( + (t & 0xFF000000) >> 24, + (t & 0xFF0000) >> 16, + (t & 0xFF00) >> 8, + t & 0xFF, + ) + ts_chars = ''.join(map(chr, ts)) + return bytes_(ip_chars + ts_chars) + + +class AuthTktCookieHelper: + """ + A helper class used for constructing a :term:`security policy` with stores + the user identity in a signed cookie. + + Constructor Arguments + + ``secret`` + + 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. + + ``cookie_name`` + + Default: ``auth_tkt``. The cookie name used + (string). Optional. + + ``secure`` + + Default: ``False``. Only send the cookie back over a secure + conn. Optional. + + ``include_ip`` + + Default: ``False``. Make the requesting IP address part of + the authentication data in the cookie. Optional. + + For IPv6 this option is not recommended. The ``mod_auth_tkt`` + specification does not specify how to handle IPv6 addresses, so using + this option in combination with IPv6 addresses may cause an + incompatible cookie. It ties the authentication ticket to that + individual's IPv6 address. + + ``timeout`` + + Default: ``None``. Maximum number of seconds which a newly + issued ticket will be considered valid. After this amount of + time, the ticket will expire (effectively logging the user + out). If this value is ``None``, the ticket never expires. + Optional. + + ``reissue_time`` + + Default: ``None``. If this parameter is set, it represents the number + of seconds that must pass before an authentication token cookie is + automatically reissued as the result of a request which requires + authentication. The duration is measured as the number of seconds + since the last auth_tkt cookie was issued and 'now'. If this value is + ``0``, a new ticket cookie will be reissued on every request which + requires authentication. + + 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 + if so. However, such a configuration is not explicitly prevented. + + Optional. + + ``max_age`` + + Default: ``None``. The max age of the auth_tkt cookie, in + seconds. This differs from ``timeout`` inasmuch as ``timeout`` + represents the lifetime of the ticket contained in the cookie, + while this value represents the lifetime of the cookie itself. + When this value is set, the cookie's ``Max-Age`` and + ``Expires`` settings will be set, allowing the auth_tkt cookie + to last between browser sessions. It is typically nonsensical + to set this to a value that is lower than ``timeout`` or + ``reissue_time``, although it is not explicitly prevented. + Optional. + + ``path`` + + Default: ``/``. The path for which the auth_tkt cookie is valid. + May be desirable if the application only serves part of a domain. + Optional. + + ``http_only`` + + Default: ``False``. Hide cookie from JavaScript by setting the + HttpOnly flag. Not honored by all browsers. + Optional. + + ``wild_domain`` + + Default: ``True``. An auth_tkt cookie will be generated for the + wildcard domain. If your site is hosted as ``example.com`` this + will make the cookie available for sites underneath ``example.com`` + such as ``www.example.com``. + Optional. + + ``parent_domain`` + + Default: ``False``. An auth_tkt cookie will be generated for the + parent domain of the current site. For example if your site is + hosted under ``www.example.com`` a cookie will be generated for + ``.example.com``. This can be useful if you have multiple sites + sharing the same domain. This option supercedes the ``wild_domain`` + option. + Optional. + + ``domain`` + + Default: ``None``. If provided the auth_tkt cookie will only be + set for this domain. This option is not compatible with ``wild_domain`` + and ``parent_domain``. + Optional. + + ``hashalg`` + + Default: ``sha512`` (the literal string). + + Any hash algorithm supported by Python's ``hashlib.new()`` function + can be used as the ``hashalg``. + + Cookies generated by different instances of AuthTktAuthenticationPolicy + using different ``hashalg`` options are not compatible. Switching the + ``hashalg`` will imply that all existing users with a valid cookie will + be required to re-login. + + Optional. + + ``samesite`` + + Default: ``'Lax'``. The 'samesite' option of the session cookie. Set + the value to ``None`` to turn off the samesite option. + + This option is available as of :app:`Pyramid` 1.10. + """ + + parse_ticket = staticmethod(parse_ticket) # for tests + AuthTicket = AuthTicket # for tests + BadTicket = BadTicket # for tests + now = None # for tests + + userid_type_decoders = { + 'int': int, + 'unicode': lambda x: utf_8_decode(x)[0], # bw compat for old cookies + 'b64unicode': lambda x: utf_8_decode(b64decode(x))[0], + 'b64str': lambda x: b64decode(x), + } + + userid_type_encoders = { + int: ('int', str), + str: ('b64unicode', lambda x: b64encode(utf_8_encode(x)[0])), + bytes: ('b64str', lambda x: b64encode(x)), + } + + def __init__( + self, + secret, + cookie_name='auth_tkt', + secure=False, + include_ip=False, + timeout=None, + reissue_time=None, + max_age=None, + http_only=False, + path="/", + wild_domain=True, + hashalg='md5', + parent_domain=False, + domain=None, + samesite='Lax', + ): + self.cookie_profile = CookieProfile( + cookie_name=cookie_name, + secure=secure, + max_age=max_age, + httponly=http_only, + path=path, + serializer=SimpleSerializer(), + samesite=samesite, + ) + + self.secret = secret + self.cookie_name = cookie_name + self.secure = secure + self.include_ip = include_ip + self.timeout = timeout if timeout is None else int(timeout) + self.reissue_time = ( + reissue_time if reissue_time is None else int(reissue_time) + ) + self.max_age = max_age if max_age is None else int(max_age) + self.wild_domain = wild_domain + self.parent_domain = parent_domain + self.domain = domain + self.hashalg = hashalg + + def _get_cookies(self, request, value, max_age=None): + cur_domain = request.domain + + domains = [] + if self.domain: + domains.append(self.domain) + else: + if self.parent_domain and cur_domain.count('.') > 1: + domains.append('.' + cur_domain.split('.', 1)[1]) + else: + domains.append(None) + domains.append(cur_domain) + if self.wild_domain: + domains.append('.' + cur_domain) + + profile = self.cookie_profile(request) + + kw = {} + kw['domains'] = domains + if max_age is not None: + kw['max_age'] = max_age + + headers = profile.get_headers(value, **kw) + return headers + + def identify(self, request): + """ Return a dictionary with authentication information, or ``None`` + if no valid auth_tkt is attached to ``request``""" + environ = request.environ + cookie = request.cookies.get(self.cookie_name) + + if cookie is None: + return None + + if self.include_ip: + remote_addr = environ['REMOTE_ADDR'] + else: + remote_addr = '0.0.0.0' + + try: + timestamp, userid, tokens, user_data = self.parse_ticket( + self.secret, cookie, remote_addr, self.hashalg + ) + except self.BadTicket: + return None + + now = self.now # service tests + + if now is None: + now = time_mod.time() + + if self.timeout and ((timestamp + self.timeout) < now): + # the auth_tkt data has expired + return None + + userid_typename = 'userid_type:' + user_data_info = user_data.split('|') + for datum in filter(None, user_data_info): + if datum.startswith(userid_typename): + userid_type = datum[len(userid_typename) :] + decoder = self.userid_type_decoders.get(userid_type) + if decoder: + userid = decoder(userid) + + reissue = self.reissue_time is not None + + if reissue and not hasattr(request, '_authtkt_reissued'): + if (now - timestamp) > self.reissue_time: + # See https://github.com/Pylons/pyramid/issues#issue/108 + tokens = list(filter(None, tokens)) + headers = self.remember( + request, userid, max_age=self.max_age, tokens=tokens + ) + + def reissue_authtkt(request, response): + if not hasattr(request, '_authtkt_reissue_revoked'): + for k, v in headers: + response.headerlist.append((k, v)) + + request.add_response_callback(reissue_authtkt) + request._authtkt_reissued = True + + environ['REMOTE_USER_TOKENS'] = tokens + environ['REMOTE_USER_DATA'] = user_data + environ['AUTH_TYPE'] = 'cookie' + + identity = {} + identity['timestamp'] = timestamp + identity['userid'] = userid + identity['tokens'] = tokens + identity['userdata'] = user_data + return identity + + def forget(self, request): + """ Return a set of expires Set-Cookie headers, which will destroy + any existing auth_tkt cookie when attached to a response""" + request._authtkt_reissue_revoked = True + return self._get_cookies(request, None) + + def remember(self, request, userid, max_age=None, tokens=()): + """ Return a set of Set-Cookie headers; when set into a response, + these headers will represent a valid authentication ticket. + + ``max_age`` + The max age of the auth_tkt cookie, in seconds. When this value is + set, the cookie's ``Max-Age`` and ``Expires`` settings will be set, + allowing the auth_tkt cookie to last between browser sessions. If + this value is ``None``, the ``max_age`` value provided to the + helper itself will be used as the ``max_age`` value. Default: + ``None``. + + ``tokens`` + A sequence of strings that will be placed into the auth_tkt tokens + field. Each string in the sequence must be of the Python ``str`` + type and must match the regex ``^[A-Za-z][A-Za-z0-9+_-]*$``. + Tokens are available in the returned identity when an auth_tkt is + found in the request and unpacked. Default: ``()``. + """ + max_age = self.max_age if max_age is None else int(max_age) + + environ = request.environ + + if self.include_ip: + remote_addr = environ['REMOTE_ADDR'] + else: + remote_addr = '0.0.0.0' + + user_data = '' + + encoding_data = self.userid_type_encoders.get(type(userid)) + + if encoding_data: + encoding, encoder = encoding_data + else: + warnings.warn( + "userid is of type {}, and is not supported by the " + "AuthTktAuthenticationPolicy. Explicitly converting to string " + "and storing as base64. Subsequent requests will receive a " + "string as the userid, it will not be decoded back to the " + "type provided.".format(type(userid)), + RuntimeWarning, + ) + encoding, encoder = self.userid_type_encoders.get(str) + userid = str(userid) + + userid = encoder(userid) + user_data = 'userid_type:%s' % encoding + + new_tokens = [] + for token in tokens: + if isinstance(token, str): + try: + token = ascii_(token) + except UnicodeEncodeError: + raise ValueError("Invalid token %r" % (token,)) + if not (isinstance(token, str) and VALID_TOKEN.match(token)): + raise ValueError("Invalid token %r" % (token,)) + new_tokens.append(token) + tokens = tuple(new_tokens) + + if hasattr(request, '_authtkt_reissued'): + request._authtkt_reissue_revoked = True + + ticket = self.AuthTicket( + self.secret, + userid, + remote_addr, + tokens=tokens, + user_data=user_data, + cookie_name=self.cookie_name, + secure=self.secure, + hashalg=self.hashalg, + ) + + cookie_value = ticket.cookie_value() + return self._get_cookies(request, cookie_value, max_age) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 8671eba05..89cf9866d 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,8 +1,7 @@ -from http.cookies import SimpleCookie import unittest import warnings from pyramid import testing -from pyramid.util import text_, bytes_ +from pyramid.util import bytes_ class TestCallbackAuthenticationPolicyDebugging(unittest.TestCase): @@ -664,926 +663,6 @@ class TestAuthTktAuthenticationPolicy(unittest.TestCase): verifyObject(IAuthenticationPolicy, self._makeOne(None, None)) -class TestAuthTktCookieHelper(unittest.TestCase): - def _getTargetClass(self): - from pyramid.authentication import AuthTktCookieHelper - - return AuthTktCookieHelper - - def _makeOne(self, *arg, **kw): - helper = self._getTargetClass()(*arg, **kw) - # laziness after moving auth_tkt classes and funcs into - # authentication module - auth_tkt = DummyAuthTktModule() - helper.auth_tkt = auth_tkt - helper.AuthTicket = auth_tkt.AuthTicket - helper.parse_ticket = auth_tkt.parse_ticket - helper.BadTicket = auth_tkt.BadTicket - return helper - - def _makeRequest(self, cookie=None, ipv6=False): - environ = {'wsgi.version': (1, 0)} - - if ipv6 is False: - environ['REMOTE_ADDR'] = '1.1.1.1' - else: - environ['REMOTE_ADDR'] = '::1' - environ['SERVER_NAME'] = 'localhost' - return DummyRequest(environ, cookie=cookie) - - def _cookieValue(self, cookie): - items = cookie.value.split('/') - D = {} - for item in items: - k, v = item.split('=', 1) - D[k] = v - return D - - def _parseHeaders(self, headers): - return [self._parseHeader(header) for header in headers] - - def _parseHeader(self, header): - cookie = self._parseCookie(header[1]) - return cookie - - def _parseCookie(self, cookie): - cookies = SimpleCookie() - cookies.load(cookie) - return cookies.get('auth_tkt') - - def test_init_cookie_str_reissue_invalid(self): - self.assertRaises( - ValueError, self._makeOne, 'secret', reissue_time='invalid value' - ) - - def test_init_cookie_str_timeout_invalid(self): - self.assertRaises( - ValueError, self._makeOne, 'secret', timeout='invalid value' - ) - - def test_init_cookie_str_max_age_invalid(self): - self.assertRaises( - ValueError, self._makeOne, 'secret', max_age='invalid value' - ) - - def test_identify_nocookie(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.identify(request) - self.assertEqual(result, None) - - def test_identify_cookie_value_is_None(self): - helper = self._makeOne('secret') - request = self._makeRequest(None) - result = helper.identify(request) - self.assertEqual(result, None) - - def test_identify_good_cookie_include_ip(self): - helper = self._makeOne('secret', include_ip=True) - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 'userid') - self.assertEqual(result['userdata'], '') - self.assertEqual(result['timestamp'], 0) - self.assertEqual(helper.auth_tkt.value, 'ticket') - self.assertEqual(helper.auth_tkt.remote_addr, '1.1.1.1') - self.assertEqual(helper.auth_tkt.secret, 'secret') - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], '') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_include_ipv6(self): - helper = self._makeOne('secret', include_ip=True) - request = self._makeRequest('ticket', ipv6=True) - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 'userid') - self.assertEqual(result['userdata'], '') - self.assertEqual(result['timestamp'], 0) - self.assertEqual(helper.auth_tkt.value, 'ticket') - self.assertEqual(helper.auth_tkt.remote_addr, '::1') - self.assertEqual(helper.auth_tkt.secret, 'secret') - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], '') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_dont_include_ip(self): - helper = self._makeOne('secret', include_ip=False) - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 'userid') - self.assertEqual(result['userdata'], '') - self.assertEqual(result['timestamp'], 0) - self.assertEqual(helper.auth_tkt.value, 'ticket') - self.assertEqual(helper.auth_tkt.remote_addr, '0.0.0.0') - self.assertEqual(helper.auth_tkt.secret, 'secret') - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], '') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_int_useridtype(self): - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = '1' - helper.auth_tkt.user_data = 'userid_type:int' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 1) - self.assertEqual(result['userdata'], 'userid_type:int') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:int') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_nonuseridtype_user_data(self): - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = '1' - helper.auth_tkt.user_data = 'bogus:int' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], '1') - self.assertEqual(result['userdata'], 'bogus:int') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'bogus:int') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_unknown_useridtype(self): - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = 'abc' - helper.auth_tkt.user_data = 'userid_type:unknown' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 'abc') - self.assertEqual(result['userdata'], 'userid_type:unknown') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:unknown') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_b64str_useridtype(self): - from base64 import b64encode - - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = b64encode(b'encoded').strip() - helper.auth_tkt.user_data = 'userid_type:b64str' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], b'encoded') - self.assertEqual(result['userdata'], 'userid_type:b64str') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:b64str') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_b64unicode_useridtype(self): - from base64 import b64encode - - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = b64encode(b'\xc3\xa9ncoded').strip() - helper.auth_tkt.user_data = 'userid_type:b64unicode' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], text_(b'\xc3\xa9ncoded', 'utf-8')) - self.assertEqual(result['userdata'], 'userid_type:b64unicode') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:b64unicode') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_bad_cookie(self): - helper = self._makeOne('secret', include_ip=True) - helper.auth_tkt.parse_raise = True - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(result, None) - - def test_identify_cookie_timeout(self): - helper = self._makeOne('secret', timeout=1) - self.assertEqual(helper.timeout, 1) - - def test_identify_cookie_str_timeout(self): - helper = self._makeOne('secret', timeout='1') - self.assertEqual(helper.timeout, 1) - - def test_identify_cookie_timeout_aged(self): - import time - - helper = self._makeOne('secret', timeout=10) - now = time.time() - helper.auth_tkt.timestamp = now - 1 - helper.now = now + 10 - helper.auth_tkt.tokens = (text_('a'),) - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertFalse(result) - - def test_identify_cookie_reissue(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - helper.auth_tkt.tokens = (text_('a'),) - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](request, response) - self.assertEqual(len(response.headerlist), 3) - self.assertEqual(response.headerlist[0][0], 'Set-Cookie') - - def test_identify_cookie_str_reissue(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time='0') - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - helper.auth_tkt.tokens = (text_('a'),) - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](request, response) - self.assertEqual(len(response.headerlist), 3) - self.assertEqual(response.headerlist[0][0], 'Set-Cookie') - - def test_identify_cookie_reissue_already_reissued_this_request(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - request._authtkt_reissued = True - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 0) - - def test_identify_cookie_reissue_notyet(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=10) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 0) - - def test_identify_cookie_reissue_revoked_by_forget(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - result = helper.forget(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](request, response) - self.assertEqual(len(response.headerlist), 0) - - def test_identify_cookie_reissue_revoked_by_remember(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - result = helper.remember(request, 'bob') - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](request, response) - self.assertEqual(len(response.headerlist), 0) - - def test_identify_cookie_reissue_with_tokens_default(self): - # see https://github.com/Pylons/pyramid/issues#issue/108 - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - auth_tkt = DummyAuthTktModule(tokens=['']) - helper.auth_tkt = auth_tkt - helper.AuthTicket = auth_tkt.AuthTicket - helper.parse_ticket = auth_tkt.parse_ticket - helper.BadTicket = auth_tkt.BadTicket - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](None, response) - self.assertEqual(len(response.headerlist), 3) - self.assertEqual(response.headerlist[0][0], 'Set-Cookie') - self.assertTrue("/tokens=/" in response.headerlist[0][1]) - - def test_remember(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue( - result[2][1].endswith('; Domain=.localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_nondefault_samesite(self): - helper = self._makeOne('secret', samesite='Strict') - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Strict')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith( - '; Domain=localhost; Path=/; SameSite=Strict' - ) - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue( - result[2][1].endswith( - '; Domain=.localhost; Path=/; SameSite=Strict' - ) - ) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_None_samesite(self): - helper = self._makeOne('secret', samesite=None) - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/')) # no samesite - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue(result[1][1].endswith('; Domain=localhost; Path=/')) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue(result[2][1].endswith('; Domain=.localhost; Path=/')) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_include_ip(self): - helper = self._makeOne('secret', include_ip=True) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue( - result[2][1].endswith('; Domain=.localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_path(self): - helper = self._makeOne( - 'secret', include_ip=True, path="/cgi-bin/app.cgi/" - ) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue( - result[0][1].endswith('; Path=/cgi-bin/app.cgi/; SameSite=Lax') - ) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith( - '; Domain=localhost; Path=/cgi-bin/app.cgi/; SameSite=Lax' - ) - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue( - result[2][1].endswith( - '; Domain=.localhost; Path=/cgi-bin/app.cgi/; SameSite=Lax' - ) - ) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_http_only(self): - helper = self._makeOne('secret', include_ip=True, http_only=True) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; HttpOnly; SameSite=Lax')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue('; HttpOnly' in result[1][1]) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue('; HttpOnly' in result[2][1]) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_secure(self): - helper = self._makeOne('secret', include_ip=True, secure=True) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue('; secure' in result[0][1]) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue('; secure' in result[1][1]) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue('; secure' in result[2][1]) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_wild_domain_disabled(self): - helper = self._makeOne('secret', wild_domain=False) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 2) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - def test_remember_parent_domain(self): - helper = self._makeOne('secret', parent_domain=True) - request = self._makeRequest() - request.domain = 'www.example.com' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 1) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue( - result[0][1].endswith( - '; Domain=.example.com; Path=/; SameSite=Lax' - ) - ) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - def test_remember_parent_domain_supercedes_wild_domain(self): - helper = self._makeOne('secret', parent_domain=True, wild_domain=True) - request = self._makeRequest() - request.domain = 'www.example.com' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 1) - self.assertTrue( - result[0][1].endswith( - '; Domain=.example.com; Path=/; SameSite=Lax' - ) - ) - - def test_remember_explicit_domain(self): - helper = self._makeOne('secret', domain='pyramid.bazinga') - request = self._makeRequest() - request.domain = 'www.example.com' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 1) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue( - result[0][1].endswith( - '; Domain=pyramid.bazinga; Path=/; SameSite=Lax' - ) - ) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - def test_remember_domain_supercedes_parent_and_wild_domain(self): - helper = self._makeOne( - 'secret', - domain='pyramid.bazinga', - parent_domain=True, - wild_domain=True, - ) - request = self._makeRequest() - request.domain = 'www.example.com' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 1) - self.assertTrue( - result[0][1].endswith( - '; Domain=pyramid.bazinga; Path=/; SameSite=Lax' - ) - ) - - def test_remember_binary_userid(self): - import base64 - - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, b'userid') - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - val = self._cookieValue(values[0]) - self.assertEqual( - val['userid'], text_(base64.b64encode(b'userid').strip()) - ) - self.assertEqual(val['user_data'], 'userid_type:b64str') - - def test_remember_int_userid(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 1) - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - val = self._cookieValue(values[0]) - self.assertEqual(val['userid'], '1') - self.assertEqual(val['user_data'], 'userid_type:int') - - def test_remember_unicode_userid(self): - import base64 - - helper = self._makeOne('secret') - request = self._makeRequest() - userid = text_(b'\xc2\xa9', 'utf-8') - result = helper.remember(request, userid) - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - val = self._cookieValue(values[0]) - self.assertEqual( - val['userid'], text_(base64.b64encode(userid.encode('utf-8'))) - ) - self.assertEqual(val['user_data'], 'userid_type:b64unicode') - - def test_remember_insane_userid(self): - helper = self._makeOne('secret') - request = self._makeRequest() - userid = object() - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always', RuntimeWarning) - result = helper.remember(request, userid) - self.assertTrue(str(w[-1].message).startswith('userid is of type')) - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - value = values[0] - self.assertTrue('userid' in value.value) - - def test_remember_max_age(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'userid', max_age=500) - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - - self.assertEqual(values[0]['max-age'], '500') - self.assertTrue(values[0]['expires']) - - def test_remember_str_max_age(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'userid', max_age='500') - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - - self.assertEqual(values[0]['max-age'], '500') - self.assertTrue(values[0]['expires']) - - def test_remember_str_max_age_invalid(self): - helper = self._makeOne('secret') - request = self._makeRequest() - self.assertRaises( - ValueError, - helper.remember, - request, - 'userid', - max_age='invalid value', - ) - - def test_remember_tokens(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'other', tokens=('foo', 'bar')) - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue("/tokens=foo|bar/" in result[0][1]) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue("/tokens=foo|bar/" in result[1][1]) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue("/tokens=foo|bar/" in result[2][1]) - - def test_remember_samesite_nondefault(self): - helper = self._makeOne('secret', samesite='Strict') - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - cookieval = result[0][1] - self.assertTrue( - 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - self.assertEqual(result[1][0], 'Set-Cookie') - cookieval = result[1][1] - self.assertTrue( - 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - self.assertEqual(result[2][0], 'Set-Cookie') - cookieval = result[2][1] - self.assertTrue( - 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - def test_remember_samesite_default(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - cookieval = result[0][1] - self.assertTrue( - 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - self.assertEqual(result[1][0], 'Set-Cookie') - cookieval = result[1][1] - self.assertTrue( - 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - self.assertEqual(result[2][0], 'Set-Cookie') - cookieval = result[2][1] - self.assertTrue( - 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - def test_remember_unicode_but_ascii_token(self): - helper = self._makeOne('secret') - request = self._makeRequest() - la = text_(b'foo', 'utf-8') - result = helper.remember(request, 'other', tokens=(la,)) - # tokens must be str type on both Python 2 and 3 - self.assertTrue("/tokens=foo/" in result[0][1]) - - def test_remember_nonascii_token(self): - helper = self._makeOne('secret') - request = self._makeRequest() - la = text_(b'La Pe\xc3\xb1a', 'utf-8') - self.assertRaises( - ValueError, helper.remember, request, 'other', tokens=(la,) - ) - - def test_remember_invalid_token_format(self): - helper = self._makeOne('secret') - request = self._makeRequest() - self.assertRaises( - ValueError, helper.remember, request, 'other', tokens=('foo bar',) - ) - self.assertRaises( - ValueError, helper.remember, request, 'other', tokens=('1bar',) - ) - - def test_forget(self): - helper = self._makeOne('secret') - request = self._makeRequest() - headers = helper.forget(request) - self.assertEqual(len(headers), 3) - name, value = headers[0] - self.assertEqual(name, 'Set-Cookie') - self.assertEqual( - value, - 'auth_tkt=; Max-Age=0; Path=/; ' - 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', - ) - name, value = headers[1] - self.assertEqual(name, 'Set-Cookie') - self.assertEqual( - value, - 'auth_tkt=; Domain=localhost; Max-Age=0; Path=/; ' - 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', - ) - name, value = headers[2] - self.assertEqual(name, 'Set-Cookie') - self.assertEqual( - value, - 'auth_tkt=; Domain=.localhost; Max-Age=0; Path=/; ' - 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', - ) - - -class TestAuthTicket(unittest.TestCase): - def _makeOne(self, *arg, **kw): - from pyramid.authentication import AuthTicket - - return AuthTicket(*arg, **kw) - - def test_ctor_with_tokens(self): - ticket = self._makeOne('secret', 'userid', 'ip', tokens=('a', 'b')) - self.assertEqual(ticket.tokens, 'a,b') - - def test_ctor_with_time(self): - ticket = self._makeOne('secret', 'userid', 'ip', time='time') - self.assertEqual(ticket.time, 'time') - - def test_digest(self): - ticket = self._makeOne('secret', 'userid', '0.0.0.0', time=10) - result = ticket.digest() - self.assertEqual(result, '126fd6224912187ee9ffa80e0b81420c') - - def test_digest_sha512(self): - ticket = self._makeOne( - 'secret', 'userid', '0.0.0.0', time=10, hashalg='sha512' - ) - result = ticket.digest() - self.assertEqual( - result, - '74770b2e0d5b1a54c2a466ec567a40f7d7823576aa49' - '3c65fc3445e9b44097f4a80410319ef8cb256a2e60b9' - 'c2002e48a9e33a3e8ee4379352c04ef96d2cb278', - ) - - def test_cookie_value(self): - ticket = self._makeOne( - 'secret', 'userid', '0.0.0.0', time=10, tokens=('a', 'b') - ) - result = ticket.cookie_value() - self.assertEqual( - result, '66f9cc3e423dc57c91df696cf3d1f0d80000000auserid!a,b!' - ) - - def test_ipv4(self): - ticket = self._makeOne( - 'secret', 'userid', '198.51.100.1', time=10, hashalg='sha256' - ) - result = ticket.cookie_value() - self.assertEqual( - result, - 'b3e7156db4f8abde4439c4a6499a0668f9e7ffd7fa27b' - '798400ecdade8d76c530000000auserid!', - ) - - def test_ipv6(self): - ticket = self._makeOne( - 'secret', 'userid', '2001:db8::1', time=10, hashalg='sha256' - ) - result = ticket.cookie_value() - self.assertEqual( - result, - 'd025b601a0f12ca6d008aa35ff3a22b7d8f3d1c1456c8' - '5becf8760cd7a2fa4910000000auserid!', - ) - - -class TestBadTicket(unittest.TestCase): - def _makeOne(self, msg, expected=None): - from pyramid.authentication import BadTicket - - return BadTicket(msg, expected) - - def test_it(self): - exc = self._makeOne('msg', expected=True) - self.assertEqual(exc.expected, True) - self.assertTrue(isinstance(exc, Exception)) - - -class Test_parse_ticket(unittest.TestCase): - def _callFUT(self, secret, ticket, ip, hashalg='md5'): - from pyramid.authentication import parse_ticket - - return parse_ticket(secret, ticket, ip, hashalg) - - def _assertRaisesBadTicket(self, secret, ticket, ip, hashalg='md5'): - from pyramid.authentication import BadTicket - - self.assertRaises( - BadTicket, self._callFUT, secret, ticket, ip, hashalg - ) - - def test_bad_timestamp(self): - ticket = 'x' * 64 - self._assertRaisesBadTicket('secret', ticket, 'ip') - - def test_bad_userid_or_data(self): - ticket = 'x' * 32 + '11111111' + 'x' * 10 - self._assertRaisesBadTicket('secret', ticket, 'ip') - - def test_digest_sig_incorrect(self): - ticket = 'x' * 32 + '11111111' + 'a!b!c' - self._assertRaisesBadTicket('secret', ticket, '0.0.0.0') - - def test_correct_with_user_data(self): - ticket = text_('66f9cc3e423dc57c91df696cf3d1f0d80000000auserid!a,b!') - result = self._callFUT('secret', ticket, '0.0.0.0') - self.assertEqual(result, (10, 'userid', ['a', 'b'], '')) - - def test_correct_with_user_data_sha512(self): - ticket = text_( - '7d947cdef99bad55f8e3382a8bd089bb9dd0547f7925b7d189adc1' - '160cab0ec0e6888faa41eba641a18522b26f19109f3ffafb769767' - 'ba8a26d02aaeae56599a0000000auserid!a,b!' - ) - result = self._callFUT('secret', ticket, '0.0.0.0', 'sha512') - self.assertEqual(result, (10, 'userid', ['a', 'b'], '')) - - def test_ipv4(self): - ticket = text_( - 'b3e7156db4f8abde4439c4a6499a0668f9e7ffd7fa27b798400ecd' - 'ade8d76c530000000auserid!' - ) - result = self._callFUT('secret', ticket, '198.51.100.1', 'sha256') - self.assertEqual(result, (10, 'userid', [''], '')) - - def test_ipv6(self): - ticket = text_( - 'd025b601a0f12ca6d008aa35ff3a22b7d8f3d1c1456c85becf8760' - 'cd7a2fa4910000000auserid!' - ) - result = self._callFUT('secret', ticket, '2001:db8::1', 'sha256') - self.assertEqual(result, (10, 'userid', [''], '')) - - class TestSessionAuthenticationPolicy(unittest.TestCase): def _getTargetClass(self): from pyramid.authentication import SessionAuthenticationPolicy @@ -1911,14 +990,6 @@ class DummyContext: pass -class DummyCookies(object): - def __init__(self, cookie): - self.cookie = cookie - - def get(self, name): - return self.cookie - - class DummyRequest: domain = 'localhost' @@ -1927,10 +998,6 @@ class DummyRequest: self.session = session or {} self.registry = registry self.callbacks = [] - self.cookies = DummyCookies(cookie) - - def add_response_callback(self, callback): - self.callbacks.append(callback) class DummyWhoPlugin: @@ -1954,68 +1021,3 @@ class DummyCookieHelper: def forget(self, *arg): return [] - - -class DummyAuthTktModule(object): - def __init__( - self, - timestamp=0, - userid='userid', - tokens=(), - user_data='', - parse_raise=False, - hashalg="md5", - ): - self.timestamp = timestamp - self.userid = userid - self.tokens = tokens - self.user_data = user_data - self.parse_raise = parse_raise - self.hashalg = hashalg - - def parse_ticket(secret, value, remote_addr, hashalg): - self.secret = secret - self.value = value - self.remote_addr = remote_addr - if self.parse_raise: - raise self.BadTicket() - return self.timestamp, self.userid, self.tokens, self.user_data - - self.parse_ticket = parse_ticket - - class AuthTicket(object): - def __init__(self, secret, userid, remote_addr, **kw): - self.secret = secret - self.userid = userid - self.remote_addr = remote_addr - self.kw = kw - - def cookie_value(self): - result = { - 'secret': self.secret, - 'userid': self.userid, - 'remote_addr': self.remote_addr, - } - result.update(self.kw) - tokens = result.pop('tokens', None) - if tokens is not None: - tokens = '|'.join(tokens) - result['tokens'] = tokens - items = sorted(result.items()) - new_items = [] - for k, v in items: - if isinstance(v, bytes): - v = text_(v) - new_items.append((k, v)) - result = '/'.join(['%s=%s' % (k, v) for k, v in new_items]) - return result - - self.AuthTicket = AuthTicket - - class BadTicket(Exception): - pass - - -class DummyResponse: - def __init__(self): - self.headerlist = [] diff --git a/tests/test_security.py b/tests/test_security.py index dd5be54d7..b66632baa 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,6 +1,9 @@ import unittest +import warnings +from http.cookies import SimpleCookie from pyramid import testing +from pyramid.util import text_ class TestAllPermissionsList(unittest.TestCase): @@ -936,3 +939,1008 @@ class TestSessionAuthenticationHelper(unittest.TestCase): result = helper.forget(request) self.assertEqual(request.session.get('userid'), None) self.assertEqual(result, []) + + +class TestAuthTicket(unittest.TestCase): + def _makeOne(self, *arg, **kw): + from pyramid.security import AuthTicket + + return AuthTicket(*arg, **kw) + + def test_ctor_with_tokens(self): + ticket = self._makeOne('secret', 'userid', 'ip', tokens=('a', 'b')) + self.assertEqual(ticket.tokens, 'a,b') + + def test_ctor_with_time(self): + ticket = self._makeOne('secret', 'userid', 'ip', time='time') + self.assertEqual(ticket.time, 'time') + + def test_digest(self): + ticket = self._makeOne('secret', 'userid', '0.0.0.0', time=10) + result = ticket.digest() + self.assertEqual(result, '126fd6224912187ee9ffa80e0b81420c') + + def test_digest_sha512(self): + ticket = self._makeOne( + 'secret', 'userid', '0.0.0.0', time=10, hashalg='sha512' + ) + result = ticket.digest() + self.assertEqual( + result, + '74770b2e0d5b1a54c2a466ec567a40f7d7823576aa49' + '3c65fc3445e9b44097f4a80410319ef8cb256a2e60b9' + 'c2002e48a9e33a3e8ee4379352c04ef96d2cb278', + ) + + def test_cookie_value(self): + ticket = self._makeOne( + 'secret', 'userid', '0.0.0.0', time=10, tokens=('a', 'b') + ) + result = ticket.cookie_value() + self.assertEqual( + result, '66f9cc3e423dc57c91df696cf3d1f0d80000000auserid!a,b!' + ) + + def test_ipv4(self): + ticket = self._makeOne( + 'secret', 'userid', '198.51.100.1', time=10, hashalg='sha256' + ) + result = ticket.cookie_value() + self.assertEqual( + result, + 'b3e7156db4f8abde4439c4a6499a0668f9e7ffd7fa27b' + '798400ecdade8d76c530000000auserid!', + ) + + def test_ipv6(self): + ticket = self._makeOne( + 'secret', 'userid', '2001:db8::1', time=10, hashalg='sha256' + ) + result = ticket.cookie_value() + self.assertEqual( + result, + 'd025b601a0f12ca6d008aa35ff3a22b7d8f3d1c1456c8' + '5becf8760cd7a2fa4910000000auserid!', + ) + + +class TestBadTicket(unittest.TestCase): + def _makeOne(self, msg, expected=None): + from pyramid.security import BadTicket + + return BadTicket(msg, expected) + + def test_it(self): + exc = self._makeOne('msg', expected=True) + self.assertEqual(exc.expected, True) + self.assertTrue(isinstance(exc, Exception)) + + +class Test_parse_ticket(unittest.TestCase): + def _callFUT(self, secret, ticket, ip, hashalg='md5'): + from pyramid.security import parse_ticket + + return parse_ticket(secret, ticket, ip, hashalg) + + def _assertRaisesBadTicket(self, secret, ticket, ip, hashalg='md5'): + from pyramid.security import BadTicket + + self.assertRaises( + BadTicket, self._callFUT, secret, ticket, ip, hashalg + ) + + def test_bad_timestamp(self): + ticket = 'x' * 64 + self._assertRaisesBadTicket('secret', ticket, 'ip') + + def test_bad_userid_or_data(self): + ticket = 'x' * 32 + '11111111' + 'x' * 10 + self._assertRaisesBadTicket('secret', ticket, 'ip') + + def test_digest_sig_incorrect(self): + ticket = 'x' * 32 + '11111111' + 'a!b!c' + self._assertRaisesBadTicket('secret', ticket, '0.0.0.0') + + def test_correct_with_user_data(self): + ticket = text_('66f9cc3e423dc57c91df696cf3d1f0d80000000auserid!a,b!') + result = self._callFUT('secret', ticket, '0.0.0.0') + self.assertEqual(result, (10, 'userid', ['a', 'b'], '')) + + def test_correct_with_user_data_sha512(self): + ticket = text_( + '7d947cdef99bad55f8e3382a8bd089bb9dd0547f7925b7d189adc1' + '160cab0ec0e6888faa41eba641a18522b26f19109f3ffafb769767' + 'ba8a26d02aaeae56599a0000000auserid!a,b!' + ) + result = self._callFUT('secret', ticket, '0.0.0.0', 'sha512') + self.assertEqual(result, (10, 'userid', ['a', 'b'], '')) + + def test_ipv4(self): + ticket = text_( + 'b3e7156db4f8abde4439c4a6499a0668f9e7ffd7fa27b798400ecd' + 'ade8d76c530000000auserid!' + ) + result = self._callFUT('secret', ticket, '198.51.100.1', 'sha256') + self.assertEqual(result, (10, 'userid', [''], '')) + + def test_ipv6(self): + ticket = text_( + 'd025b601a0f12ca6d008aa35ff3a22b7d8f3d1c1456c85becf8760' + 'cd7a2fa4910000000auserid!' + ) + result = self._callFUT('secret', ticket, '2001:db8::1', 'sha256') + self.assertEqual(result, (10, 'userid', [''], '')) + + +class TestAuthTktCookieHelper(unittest.TestCase): + def _getTargetClass(self): + from pyramid.security import AuthTktCookieHelper + + return AuthTktCookieHelper + + def _makeOne(self, *arg, **kw): + helper = self._getTargetClass()(*arg, **kw) + auth_tkt = DummyAuthTktModule() + helper.auth_tkt = auth_tkt + helper.AuthTicket = auth_tkt.AuthTicket + helper.parse_ticket = auth_tkt.parse_ticket + helper.BadTicket = auth_tkt.BadTicket + return helper + + def _makeRequest(self, cookie=None, ipv6=False): + environ = {'wsgi.version': (1, 0)} + + if ipv6 is False: + environ['REMOTE_ADDR'] = '1.1.1.1' + else: + environ['REMOTE_ADDR'] = '::1' + environ['SERVER_NAME'] = 'localhost' + return DummyRequest(environ, cookie=cookie) + + def _cookieValue(self, cookie): + items = cookie.value.split('/') + D = {} + for item in items: + k, v = item.split('=', 1) + D[k] = v + return D + + def _parseHeaders(self, headers): + return [self._parseHeader(header) for header in headers] + + def _parseHeader(self, header): + cookie = self._parseCookie(header[1]) + return cookie + + def _parseCookie(self, cookie): + cookies = SimpleCookie() + cookies.load(cookie) + return cookies.get('auth_tkt') + + def test_init_cookie_str_reissue_invalid(self): + self.assertRaises( + ValueError, self._makeOne, 'secret', reissue_time='invalid value' + ) + + def test_init_cookie_str_timeout_invalid(self): + self.assertRaises( + ValueError, self._makeOne, 'secret', timeout='invalid value' + ) + + def test_init_cookie_str_max_age_invalid(self): + self.assertRaises( + ValueError, self._makeOne, 'secret', max_age='invalid value' + ) + + def test_identify_nocookie(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.identify(request) + self.assertEqual(result, None) + + def test_identify_cookie_value_is_None(self): + helper = self._makeOne('secret') + request = self._makeRequest(None) + result = helper.identify(request) + self.assertEqual(result, None) + + def test_identify_good_cookie_include_ip(self): + helper = self._makeOne('secret', include_ip=True) + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 'userid') + self.assertEqual(result['userdata'], '') + self.assertEqual(result['timestamp'], 0) + self.assertEqual(helper.auth_tkt.value, 'ticket') + self.assertEqual(helper.auth_tkt.remote_addr, '1.1.1.1') + self.assertEqual(helper.auth_tkt.secret, 'secret') + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], '') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_include_ipv6(self): + helper = self._makeOne('secret', include_ip=True) + request = self._makeRequest('ticket', ipv6=True) + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 'userid') + self.assertEqual(result['userdata'], '') + self.assertEqual(result['timestamp'], 0) + self.assertEqual(helper.auth_tkt.value, 'ticket') + self.assertEqual(helper.auth_tkt.remote_addr, '::1') + self.assertEqual(helper.auth_tkt.secret, 'secret') + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], '') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_dont_include_ip(self): + helper = self._makeOne('secret', include_ip=False) + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 'userid') + self.assertEqual(result['userdata'], '') + self.assertEqual(result['timestamp'], 0) + self.assertEqual(helper.auth_tkt.value, 'ticket') + self.assertEqual(helper.auth_tkt.remote_addr, '0.0.0.0') + self.assertEqual(helper.auth_tkt.secret, 'secret') + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], '') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_int_useridtype(self): + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = '1' + helper.auth_tkt.user_data = 'userid_type:int' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 1) + self.assertEqual(result['userdata'], 'userid_type:int') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:int') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_nonuseridtype_user_data(self): + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = '1' + helper.auth_tkt.user_data = 'bogus:int' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], '1') + self.assertEqual(result['userdata'], 'bogus:int') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'bogus:int') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_unknown_useridtype(self): + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = 'abc' + helper.auth_tkt.user_data = 'userid_type:unknown' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 'abc') + self.assertEqual(result['userdata'], 'userid_type:unknown') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:unknown') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_b64str_useridtype(self): + from base64 import b64encode + + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = b64encode(b'encoded').strip() + helper.auth_tkt.user_data = 'userid_type:b64str' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], b'encoded') + self.assertEqual(result['userdata'], 'userid_type:b64str') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:b64str') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_b64unicode_useridtype(self): + from base64 import b64encode + + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = b64encode(b'\xc3\xa9ncoded').strip() + helper.auth_tkt.user_data = 'userid_type:b64unicode' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], text_(b'\xc3\xa9ncoded', 'utf-8')) + self.assertEqual(result['userdata'], 'userid_type:b64unicode') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:b64unicode') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_bad_cookie(self): + helper = self._makeOne('secret', include_ip=True) + helper.auth_tkt.parse_raise = True + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(result, None) + + def test_identify_cookie_timeout(self): + helper = self._makeOne('secret', timeout=1) + self.assertEqual(helper.timeout, 1) + + def test_identify_cookie_str_timeout(self): + helper = self._makeOne('secret', timeout='1') + self.assertEqual(helper.timeout, 1) + + def test_identify_cookie_timeout_aged(self): + import time + + helper = self._makeOne('secret', timeout=10) + now = time.time() + helper.auth_tkt.timestamp = now - 1 + helper.now = now + 10 + helper.auth_tkt.tokens = (text_('a'),) + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertFalse(result) + + def test_identify_cookie_reissue(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + helper.auth_tkt.tokens = (text_('a'),) + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](request, response) + self.assertEqual(len(response.headerlist), 3) + self.assertEqual(response.headerlist[0][0], 'Set-Cookie') + + def test_identify_cookie_str_reissue(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time='0') + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + helper.auth_tkt.tokens = (text_('a'),) + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](request, response) + self.assertEqual(len(response.headerlist), 3) + self.assertEqual(response.headerlist[0][0], 'Set-Cookie') + + def test_identify_cookie_reissue_already_reissued_this_request(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + request._authtkt_reissued = True + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 0) + + def test_identify_cookie_reissue_notyet(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=10) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 0) + + def test_identify_cookie_reissue_revoked_by_forget(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + result = helper.forget(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](request, response) + self.assertEqual(len(response.headerlist), 0) + + def test_identify_cookie_reissue_revoked_by_remember(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + result = helper.remember(request, 'bob') + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](request, response) + self.assertEqual(len(response.headerlist), 0) + + def test_identify_cookie_reissue_with_tokens_default(self): + # see https://github.com/Pylons/pyramid/issues#issue/108 + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + auth_tkt = DummyAuthTktModule(tokens=['']) + helper.auth_tkt = auth_tkt + helper.AuthTicket = auth_tkt.AuthTicket + helper.parse_ticket = auth_tkt.parse_ticket + helper.BadTicket = auth_tkt.BadTicket + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](None, response) + self.assertEqual(len(response.headerlist), 3) + self.assertEqual(response.headerlist[0][0], 'Set-Cookie') + self.assertTrue("/tokens=/" in response.headerlist[0][1]) + + def test_remember(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue( + result[2][1].endswith('; Domain=.localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_nondefault_samesite(self): + helper = self._makeOne('secret', samesite='Strict') + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Strict')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith( + '; Domain=localhost; Path=/; SameSite=Strict' + ) + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue( + result[2][1].endswith( + '; Domain=.localhost; Path=/; SameSite=Strict' + ) + ) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_None_samesite(self): + helper = self._makeOne('secret', samesite=None) + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/')) # no samesite + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue(result[1][1].endswith('; Domain=localhost; Path=/')) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue(result[2][1].endswith('; Domain=.localhost; Path=/')) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_include_ip(self): + helper = self._makeOne('secret', include_ip=True) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue( + result[2][1].endswith('; Domain=.localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_path(self): + helper = self._makeOne( + 'secret', include_ip=True, path="/cgi-bin/app.cgi/" + ) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue( + result[0][1].endswith('; Path=/cgi-bin/app.cgi/; SameSite=Lax') + ) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith( + '; Domain=localhost; Path=/cgi-bin/app.cgi/; SameSite=Lax' + ) + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue( + result[2][1].endswith( + '; Domain=.localhost; Path=/cgi-bin/app.cgi/; SameSite=Lax' + ) + ) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_http_only(self): + helper = self._makeOne('secret', include_ip=True, http_only=True) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; HttpOnly; SameSite=Lax')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue('; HttpOnly' in result[1][1]) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue('; HttpOnly' in result[2][1]) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_secure(self): + helper = self._makeOne('secret', include_ip=True, secure=True) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue('; secure' in result[0][1]) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue('; secure' in result[1][1]) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue('; secure' in result[2][1]) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_wild_domain_disabled(self): + helper = self._makeOne('secret', wild_domain=False) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 2) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + def test_remember_parent_domain(self): + helper = self._makeOne('secret', parent_domain=True) + request = self._makeRequest() + request.domain = 'www.example.com' + result = helper.remember(request, 'other') + self.assertEqual(len(result), 1) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue( + result[0][1].endswith( + '; Domain=.example.com; Path=/; SameSite=Lax' + ) + ) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + def test_remember_parent_domain_supercedes_wild_domain(self): + helper = self._makeOne('secret', parent_domain=True, wild_domain=True) + request = self._makeRequest() + request.domain = 'www.example.com' + result = helper.remember(request, 'other') + self.assertEqual(len(result), 1) + self.assertTrue( + result[0][1].endswith( + '; Domain=.example.com; Path=/; SameSite=Lax' + ) + ) + + def test_remember_explicit_domain(self): + helper = self._makeOne('secret', domain='pyramid.bazinga') + request = self._makeRequest() + request.domain = 'www.example.com' + result = helper.remember(request, 'other') + self.assertEqual(len(result), 1) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue( + result[0][1].endswith( + '; Domain=pyramid.bazinga; Path=/; SameSite=Lax' + ) + ) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + def test_remember_domain_supercedes_parent_and_wild_domain(self): + helper = self._makeOne( + 'secret', + domain='pyramid.bazinga', + parent_domain=True, + wild_domain=True, + ) + request = self._makeRequest() + request.domain = 'www.example.com' + result = helper.remember(request, 'other') + self.assertEqual(len(result), 1) + self.assertTrue( + result[0][1].endswith( + '; Domain=pyramid.bazinga; Path=/; SameSite=Lax' + ) + ) + + def test_remember_binary_userid(self): + import base64 + + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, b'userid') + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + val = self._cookieValue(values[0]) + self.assertEqual( + val['userid'], text_(base64.b64encode(b'userid').strip()) + ) + self.assertEqual(val['user_data'], 'userid_type:b64str') + + def test_remember_int_userid(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 1) + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + val = self._cookieValue(values[0]) + self.assertEqual(val['userid'], '1') + self.assertEqual(val['user_data'], 'userid_type:int') + + def test_remember_unicode_userid(self): + import base64 + + helper = self._makeOne('secret') + request = self._makeRequest() + userid = text_(b'\xc2\xa9', 'utf-8') + result = helper.remember(request, userid) + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + val = self._cookieValue(values[0]) + self.assertEqual( + val['userid'], text_(base64.b64encode(userid.encode('utf-8'))) + ) + self.assertEqual(val['user_data'], 'userid_type:b64unicode') + + def test_remember_insane_userid(self): + helper = self._makeOne('secret') + request = self._makeRequest() + userid = object() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always', RuntimeWarning) + result = helper.remember(request, userid) + self.assertTrue(str(w[-1].message).startswith('userid is of type')) + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + value = values[0] + self.assertTrue('userid' in value.value) + + def test_remember_max_age(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'userid', max_age=500) + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + + self.assertEqual(values[0]['max-age'], '500') + self.assertTrue(values[0]['expires']) + + def test_remember_str_max_age(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'userid', max_age='500') + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + + self.assertEqual(values[0]['max-age'], '500') + self.assertTrue(values[0]['expires']) + + def test_remember_str_max_age_invalid(self): + helper = self._makeOne('secret') + request = self._makeRequest() + self.assertRaises( + ValueError, + helper.remember, + request, + 'userid', + max_age='invalid value', + ) + + def test_remember_tokens(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'other', tokens=('foo', 'bar')) + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue("/tokens=foo|bar/" in result[0][1]) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue("/tokens=foo|bar/" in result[1][1]) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue("/tokens=foo|bar/" in result[2][1]) + + def test_remember_samesite_nondefault(self): + helper = self._makeOne('secret', samesite='Strict') + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + cookieval = result[0][1] + self.assertTrue( + 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + self.assertEqual(result[1][0], 'Set-Cookie') + cookieval = result[1][1] + self.assertTrue( + 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + self.assertEqual(result[2][0], 'Set-Cookie') + cookieval = result[2][1] + self.assertTrue( + 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + def test_remember_samesite_default(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + cookieval = result[0][1] + self.assertTrue( + 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + self.assertEqual(result[1][0], 'Set-Cookie') + cookieval = result[1][1] + self.assertTrue( + 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + self.assertEqual(result[2][0], 'Set-Cookie') + cookieval = result[2][1] + self.assertTrue( + 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + def test_remember_unicode_but_ascii_token(self): + helper = self._makeOne('secret') + request = self._makeRequest() + la = text_(b'foo', 'utf-8') + result = helper.remember(request, 'other', tokens=(la,)) + # tokens must be str type on both Python 2 and 3 + self.assertTrue("/tokens=foo/" in result[0][1]) + + def test_remember_nonascii_token(self): + helper = self._makeOne('secret') + request = self._makeRequest() + la = text_(b'La Pe\xc3\xb1a', 'utf-8') + self.assertRaises( + ValueError, helper.remember, request, 'other', tokens=(la,) + ) + + def test_remember_invalid_token_format(self): + helper = self._makeOne('secret') + request = self._makeRequest() + self.assertRaises( + ValueError, helper.remember, request, 'other', tokens=('foo bar',) + ) + self.assertRaises( + ValueError, helper.remember, request, 'other', tokens=('1bar',) + ) + + def test_forget(self): + helper = self._makeOne('secret') + request = self._makeRequest() + headers = helper.forget(request) + self.assertEqual(len(headers), 3) + name, value = headers[0] + self.assertEqual(name, 'Set-Cookie') + self.assertEqual( + value, + 'auth_tkt=; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', + ) + name, value = headers[1] + self.assertEqual(name, 'Set-Cookie') + self.assertEqual( + value, + 'auth_tkt=; Domain=localhost; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', + ) + name, value = headers[2] + self.assertEqual(name, 'Set-Cookie') + self.assertEqual( + value, + 'auth_tkt=; Domain=.localhost; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', + ) + + +class DummyAuthTktModule(object): + def __init__( + self, + timestamp=0, + userid='userid', + tokens=(), + user_data='', + parse_raise=False, + hashalg="md5", + ): + self.timestamp = timestamp + self.userid = userid + self.tokens = tokens + self.user_data = user_data + self.parse_raise = parse_raise + self.hashalg = hashalg + + def parse_ticket(secret, value, remote_addr, hashalg): + self.secret = secret + self.value = value + self.remote_addr = remote_addr + if self.parse_raise: + raise self.BadTicket() + return self.timestamp, self.userid, self.tokens, self.user_data + + self.parse_ticket = parse_ticket + + class AuthTicket(object): + def __init__(self, secret, userid, remote_addr, **kw): + self.secret = secret + self.userid = userid + self.remote_addr = remote_addr + self.kw = kw + + def cookie_value(self): + result = { + 'secret': self.secret, + 'userid': self.userid, + 'remote_addr': self.remote_addr, + } + result.update(self.kw) + tokens = result.pop('tokens', None) + if tokens is not None: + tokens = '|'.join(tokens) + result['tokens'] = tokens + items = sorted(result.items()) + new_items = [] + for k, v in items: + if isinstance(v, bytes): + v = text_(v) + new_items.append((k, v)) + result = '/'.join(['%s=%s' % (k, v) for k, v in new_items]) + return result + + self.AuthTicket = AuthTicket + + class BadTicket(Exception): + pass + + +class DummyCookies(object): + def __init__(self, cookie): + self.cookie = cookie + + def get(self, name): + return self.cookie + + +class DummyRequest: + domain = 'localhost' + + def __init__(self, environ=None, session=None, registry=None, cookie=None): + self.environ = environ or {} + self.session = session or {} + self.registry = registry + self.callbacks = [] + self.cookies = DummyCookies(cookie) + + def add_response_callback(self, callback): + self.callbacks.append(callback) + + +class DummyResponse: + def __init__(self): + self.headerlist = [] -- cgit v1.2.3 From 8536f3ab834b4da7cc24a88855a3f883929b5b1e Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Mon, 15 Apr 2019 19:01:37 -0700 Subject: Register security policy in phase 2. --- src/pyramid/config/security.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pyramid/config/security.py b/src/pyramid/config/security.py index ac7dcef43..e8ea0878d 100644 --- a/src/pyramid/config/security.py +++ b/src/pyramid/config/security.py @@ -49,8 +49,12 @@ class SecurityConfiguratorMixin(object): 'security policy', ) intr['policy'] = policy - # authentication policy used by view config (phase 3) - self.action(ISecurityPolicy, register, introspectables=(intr,)) + self.action( + ISecurityPolicy, + register, + order=PHASE2_CONFIG, + introspectables=(intr,), + ) @action_method def set_authentication_policy(self, policy): -- cgit v1.2.3 From 47f8935bb5423718ec293bba4709307be7d2f51c Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Mon, 15 Apr 2019 19:04:35 -0700 Subject: Stringify identity in legacy authenticated_userid. --- src/pyramid/security.py | 2 +- tests/test_security.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/pyramid/security.py b/src/pyramid/security.py index e28ce0f1c..a55320ce6 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -355,7 +355,7 @@ class AuthenticationAPIMixin(object): if authn is not None: return authn.authenticated_userid(self) elif security is not None: - return security.identify(self) + return str(security.identify(self)) else: return None diff --git a/tests/test_security.py b/tests/test_security.py index b66632baa..97aec42c6 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -357,8 +357,9 @@ class TestAuthenticatedUserId(unittest.TestCase): def test_with_security_policy(self): request = _makeRequest() - _registerSecurityPolicy(request.registry, 'yo') - self.assertEqual(request.authenticated_userid, 'yo') + # Ensure the identity is stringified. + _registerSecurityPolicy(request.registry, 123) + self.assertEqual(request.authenticated_userid, '123') def test_with_authentication_policy_no_reg_on_request(self): from pyramid.threadlocal import get_current_registry -- cgit v1.2.3 From f4c6c993ded900b4e32d1dd49207ca1e18b11336 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Mon, 15 Apr 2019 19:05:47 -0700 Subject: Revert "Migrate AuthTktCookieHelper to pyramid.security." This reverts commit 9f267dd842c5e93336f0392f2809da75a716039a. --- src/pyramid/authentication.py | 444 +++++++++++++++++- src/pyramid/security.py | 558 +---------------------- tests/test_authentication.py | 1000 +++++++++++++++++++++++++++++++++++++++- tests/test_security.py | 1008 ----------------------------------------- 4 files changed, 1431 insertions(+), 1579 deletions(-) diff --git a/src/pyramid/authentication.py b/src/pyramid/authentication.py index 12f31e5dd..21cfc0c0e 100644 --- a/src/pyramid/authentication.py +++ b/src/pyramid/authentication.py @@ -1,23 +1,26 @@ import binascii +from codecs import utf_8_decode +from codecs import utf_8_encode from collections import namedtuple +import hashlib +import base64 +import re +import time as time_mod +from urllib.parse import quote, unquote +import warnings from zope.interface import implementer +from webob.cookies import CookieProfile + from pyramid.interfaces import IAuthenticationPolicy, IDebugLogger -from pyramid.security import Authenticated, Everyone, AuthTktCookieHelper - -# bw compat after moving AuthTktHelper and friends to pyramid.security -from pyramid.security import ( # noqa - VALID_TOKEN, - b64encode, - b64decode, - AuthTicket, - BadTicket, - parse_ticket, - calculate_digest, - encode_ip_timestamp, -) +from pyramid.security import Authenticated, Everyone + +from pyramid.util import strings_differ, bytes_, ascii_, text_ +from pyramid.util import SimpleSerializer + +VALID_TOKEN = re.compile(r"^[A-Za-z][A-Za-z0-9+_-]*$") class CallbackAuthenticationPolicy(object): @@ -648,6 +651,421 @@ class AuthTktAuthenticationPolicy(CallbackAuthenticationPolicy): return self.cookie.forget(request) +def b64encode(v): + return base64.b64encode(bytes_(v)).strip().replace(b'\n', b'') + + +def b64decode(v): + return base64.b64decode(bytes_(v)) + + +# this class licensed under the MIT license (stolen from Paste) +class AuthTicket(object): + """ + This class represents an authentication token. You must pass in + the shared secret, the userid, and the IP address. Optionally you + can include tokens (a list of strings, representing role names), + 'user_data', which is arbitrary data available for your own use in + later scripts. Lastly, you can override the cookie name and + timestamp. + + Once you provide all the arguments, use .cookie_value() to + generate the appropriate authentication ticket. + + Usage:: + + token = AuthTicket('sharedsecret', 'username', + os.environ['REMOTE_ADDR'], tokens=['admin']) + val = token.cookie_value() + + """ + + def __init__( + self, + secret, + userid, + ip, + tokens=(), + user_data='', + time=None, + cookie_name='auth_tkt', + secure=False, + hashalg='md5', + ): + self.secret = secret + self.userid = userid + self.ip = ip + self.tokens = ','.join(tokens) + self.user_data = user_data + if time is None: + self.time = time_mod.time() + else: + self.time = time + self.cookie_name = cookie_name + self.secure = secure + self.hashalg = hashalg + + def digest(self): + return calculate_digest( + self.ip, + self.time, + self.secret, + self.userid, + self.tokens, + self.user_data, + self.hashalg, + ) + + def cookie_value(self): + v = '%s%08x%s!' % (self.digest(), int(self.time), quote(self.userid)) + if self.tokens: + v += self.tokens + '!' + v += self.user_data + return v + + +# this class licensed under the MIT license (stolen from Paste) +class BadTicket(Exception): + """ + Exception raised when a ticket can't be parsed. If we get far enough to + determine what the expected digest should have been, expected is set. + This should not be shown by default, but can be useful for debugging. + """ + + def __init__(self, msg, expected=None): + self.expected = expected + Exception.__init__(self, msg) + + +# this function licensed under the MIT license (stolen from Paste) +def parse_ticket(secret, ticket, ip, hashalg='md5'): + """ + Parse the ticket, returning (timestamp, userid, tokens, user_data). + + If the ticket cannot be parsed, a ``BadTicket`` exception will be raised + with an explanation. + """ + ticket = text_(ticket).strip('"') + digest_size = hashlib.new(hashalg).digest_size * 2 + digest = ticket[:digest_size] + try: + timestamp = int(ticket[digest_size : digest_size + 8], 16) + except ValueError as e: + raise BadTicket('Timestamp is not a hex integer: %s' % e) + try: + userid, data = ticket[digest_size + 8 :].split('!', 1) + except ValueError: + raise BadTicket('userid is not followed by !') + userid = unquote(userid) + if '!' in data: + tokens, user_data = data.split('!', 1) + else: # pragma: no cover (never generated) + # @@: Is this the right order? + tokens = '' + user_data = data + + expected = calculate_digest( + ip, timestamp, secret, userid, tokens, user_data, hashalg + ) + + # Avoid timing attacks (see + # http://seb.dbzteam.org/crypto/python-oauth-timing-hmac.pdf) + if strings_differ(expected, digest): + raise BadTicket( + 'Digest signature is not correct', expected=(expected, digest) + ) + + tokens = tokens.split(',') + + return (timestamp, userid, tokens, user_data) + + +# this function licensed under the MIT license (stolen from Paste) +def calculate_digest( + ip, timestamp, secret, userid, tokens, user_data, hashalg='md5' +): + secret = bytes_(secret, 'utf-8') + userid = bytes_(userid, 'utf-8') + tokens = bytes_(tokens, 'utf-8') + user_data = bytes_(user_data, 'utf-8') + hash_obj = hashlib.new(hashalg) + + # Check to see if this is an IPv6 address + if ':' in ip: + ip_timestamp = ip + str(int(timestamp)) + ip_timestamp = bytes_(ip_timestamp) + else: + # encode_ip_timestamp not required, left in for backwards compatibility + ip_timestamp = encode_ip_timestamp(ip, timestamp) + + hash_obj.update( + ip_timestamp + secret + userid + b'\0' + tokens + b'\0' + user_data + ) + digest = hash_obj.hexdigest() + hash_obj2 = hashlib.new(hashalg) + hash_obj2.update(bytes_(digest) + secret) + return hash_obj2.hexdigest() + + +# this function licensed under the MIT license (stolen from Paste) +def encode_ip_timestamp(ip, timestamp): + ip_chars = ''.join(map(chr, map(int, ip.split('.')))) + t = int(timestamp) + ts = ( + (t & 0xFF000000) >> 24, + (t & 0xFF0000) >> 16, + (t & 0xFF00) >> 8, + t & 0xFF, + ) + ts_chars = ''.join(map(chr, ts)) + return bytes_(ip_chars + ts_chars) + + +class AuthTktCookieHelper(object): + """ + A helper class for use in third-party authentication policy + implementations. See + :class:`pyramid.authentication.AuthTktAuthenticationPolicy` for the + meanings of the constructor arguments. + """ + + parse_ticket = staticmethod(parse_ticket) # for tests + AuthTicket = AuthTicket # for tests + BadTicket = BadTicket # for tests + now = None # for tests + + userid_type_decoders = { + 'int': int, + 'unicode': lambda x: utf_8_decode(x)[0], # bw compat for old cookies + 'b64unicode': lambda x: utf_8_decode(b64decode(x))[0], + 'b64str': lambda x: b64decode(x), + } + + userid_type_encoders = { + int: ('int', str), + str: ('b64unicode', lambda x: b64encode(utf_8_encode(x)[0])), + bytes: ('b64str', lambda x: b64encode(x)), + } + + def __init__( + self, + secret, + cookie_name='auth_tkt', + secure=False, + include_ip=False, + timeout=None, + reissue_time=None, + max_age=None, + http_only=False, + path="/", + wild_domain=True, + hashalg='md5', + parent_domain=False, + domain=None, + samesite='Lax', + ): + self.cookie_profile = CookieProfile( + cookie_name=cookie_name, + secure=secure, + max_age=max_age, + httponly=http_only, + path=path, + serializer=SimpleSerializer(), + samesite=samesite, + ) + + self.secret = secret + self.cookie_name = cookie_name + self.secure = secure + self.include_ip = include_ip + self.timeout = timeout if timeout is None else int(timeout) + self.reissue_time = ( + reissue_time if reissue_time is None else int(reissue_time) + ) + self.max_age = max_age if max_age is None else int(max_age) + self.wild_domain = wild_domain + self.parent_domain = parent_domain + self.domain = domain + self.hashalg = hashalg + + def _get_cookies(self, request, value, max_age=None): + cur_domain = request.domain + + domains = [] + if self.domain: + domains.append(self.domain) + else: + if self.parent_domain and cur_domain.count('.') > 1: + domains.append('.' + cur_domain.split('.', 1)[1]) + else: + domains.append(None) + domains.append(cur_domain) + if self.wild_domain: + domains.append('.' + cur_domain) + + profile = self.cookie_profile(request) + + kw = {} + kw['domains'] = domains + if max_age is not None: + kw['max_age'] = max_age + + headers = profile.get_headers(value, **kw) + return headers + + def identify(self, request): + """ Return a dictionary with authentication information, or ``None`` + if no valid auth_tkt is attached to ``request``""" + environ = request.environ + cookie = request.cookies.get(self.cookie_name) + + if cookie is None: + return None + + if self.include_ip: + remote_addr = environ['REMOTE_ADDR'] + else: + remote_addr = '0.0.0.0' + + try: + timestamp, userid, tokens, user_data = self.parse_ticket( + self.secret, cookie, remote_addr, self.hashalg + ) + except self.BadTicket: + return None + + now = self.now # service tests + + if now is None: + now = time_mod.time() + + if self.timeout and ((timestamp + self.timeout) < now): + # the auth_tkt data has expired + return None + + userid_typename = 'userid_type:' + user_data_info = user_data.split('|') + for datum in filter(None, user_data_info): + if datum.startswith(userid_typename): + userid_type = datum[len(userid_typename) :] + decoder = self.userid_type_decoders.get(userid_type) + if decoder: + userid = decoder(userid) + + reissue = self.reissue_time is not None + + if reissue and not hasattr(request, '_authtkt_reissued'): + if (now - timestamp) > self.reissue_time: + # See https://github.com/Pylons/pyramid/issues#issue/108 + tokens = list(filter(None, tokens)) + headers = self.remember( + request, userid, max_age=self.max_age, tokens=tokens + ) + + def reissue_authtkt(request, response): + if not hasattr(request, '_authtkt_reissue_revoked'): + for k, v in headers: + response.headerlist.append((k, v)) + + request.add_response_callback(reissue_authtkt) + request._authtkt_reissued = True + + environ['REMOTE_USER_TOKENS'] = tokens + environ['REMOTE_USER_DATA'] = user_data + environ['AUTH_TYPE'] = 'cookie' + + identity = {} + identity['timestamp'] = timestamp + identity['userid'] = userid + identity['tokens'] = tokens + identity['userdata'] = user_data + return identity + + def forget(self, request): + """ Return a set of expires Set-Cookie headers, which will destroy + any existing auth_tkt cookie when attached to a response""" + request._authtkt_reissue_revoked = True + return self._get_cookies(request, None) + + def remember(self, request, userid, max_age=None, tokens=()): + """ Return a set of Set-Cookie headers; when set into a response, + these headers will represent a valid authentication ticket. + + ``max_age`` + The max age of the auth_tkt cookie, in seconds. When this value is + set, the cookie's ``Max-Age`` and ``Expires`` settings will be set, + allowing the auth_tkt cookie to last between browser sessions. If + this value is ``None``, the ``max_age`` value provided to the + helper itself will be used as the ``max_age`` value. Default: + ``None``. + + ``tokens`` + A sequence of strings that will be placed into the auth_tkt tokens + field. Each string in the sequence must be of the Python ``str`` + type and must match the regex ``^[A-Za-z][A-Za-z0-9+_-]*$``. + Tokens are available in the returned identity when an auth_tkt is + found in the request and unpacked. Default: ``()``. + """ + max_age = self.max_age if max_age is None else int(max_age) + + environ = request.environ + + if self.include_ip: + remote_addr = environ['REMOTE_ADDR'] + else: + remote_addr = '0.0.0.0' + + user_data = '' + + encoding_data = self.userid_type_encoders.get(type(userid)) + + if encoding_data: + encoding, encoder = encoding_data + else: + warnings.warn( + "userid is of type {}, and is not supported by the " + "AuthTktAuthenticationPolicy. Explicitly converting to string " + "and storing as base64. Subsequent requests will receive a " + "string as the userid, it will not be decoded back to the " + "type provided.".format(type(userid)), + RuntimeWarning, + ) + encoding, encoder = self.userid_type_encoders.get(str) + userid = str(userid) + + userid = encoder(userid) + user_data = 'userid_type:%s' % encoding + + new_tokens = [] + for token in tokens: + if isinstance(token, str): + try: + token = ascii_(token) + except UnicodeEncodeError: + raise ValueError("Invalid token %r" % (token,)) + if not (isinstance(token, str) and VALID_TOKEN.match(token)): + raise ValueError("Invalid token %r" % (token,)) + new_tokens.append(token) + tokens = tuple(new_tokens) + + if hasattr(request, '_authtkt_reissued'): + request._authtkt_reissue_revoked = True + + ticket = self.AuthTicket( + self.secret, + userid, + remote_addr, + tokens=tokens, + user_data=user_data, + cookie_name=self.cookie_name, + secure=self.secure, + hashalg=self.hashalg, + ) + + cookie_value = ticket.cookie_value() + return self._get_cookies(request, cookie_value, max_age) + + @implementer(IAuthenticationPolicy) class SessionAuthenticationPolicy(CallbackAuthenticationPolicy): """ A :app:`Pyramid` authentication policy which gets its data from the diff --git a/src/pyramid/security.py b/src/pyramid/security.py index a55320ce6..dda61ef27 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -1,14 +1,3 @@ -from codecs import utf_8_decode -from codecs import utf_8_encode -import hashlib -import base64 -import time as time_mod -from urllib.parse import quote, unquote -import warnings -import re - -from webob.cookies import CookieProfile - from zope.interface import implementer, providedBy from pyramid.interfaces import ( @@ -22,14 +11,10 @@ from pyramid.interfaces import ( from pyramid.location import lineage -from pyramid.util import is_nonstr_iter, strings_differ, bytes_, ascii_, text_ - -from pyramid.util import SimpleSerializer +from pyramid.util import is_nonstr_iter from pyramid.threadlocal import get_current_registry -VALID_TOKEN = re.compile(r"^[A-Za-z][A-Za-z0-9+_-]*$") - Everyone = 'system.Everyone' Authenticated = 'system.Authenticated' Allow = 'Allow' @@ -586,544 +571,3 @@ class SessionAuthenticationHelper: def identify(self, request): return request.session.get(self.userid_key) - - -def b64encode(v): - return base64.b64encode(bytes_(v)).strip().replace(b'\n', b'') - - -def b64decode(v): - return base64.b64decode(bytes_(v)) - - -# this class licensed under the MIT license (stolen from Paste) -class AuthTicket(object): - """ - This class represents an authentication token. You must pass in - the shared secret, the userid, and the IP address. Optionally you - can include tokens (a list of strings, representing role names), - 'user_data', which is arbitrary data available for your own use in - later scripts. Lastly, you can override the cookie name and - timestamp. - - Once you provide all the arguments, use .cookie_value() to - generate the appropriate authentication ticket. - - Usage:: - - token = AuthTicket('sharedsecret', 'username', - os.environ['REMOTE_ADDR'], tokens=['admin']) - val = token.cookie_value() - - """ - - def __init__( - self, - secret, - userid, - ip, - tokens=(), - user_data='', - time=None, - cookie_name='auth_tkt', - secure=False, - hashalg='md5', - ): - self.secret = secret - self.userid = userid - self.ip = ip - self.tokens = ','.join(tokens) - self.user_data = user_data - if time is None: - self.time = time_mod.time() - else: - self.time = time - self.cookie_name = cookie_name - self.secure = secure - self.hashalg = hashalg - - def digest(self): - return calculate_digest( - self.ip, - self.time, - self.secret, - self.userid, - self.tokens, - self.user_data, - self.hashalg, - ) - - def cookie_value(self): - v = '%s%08x%s!' % (self.digest(), int(self.time), quote(self.userid)) - if self.tokens: - v += self.tokens + '!' - v += self.user_data - return v - - -# this class licensed under the MIT license (stolen from Paste) -class BadTicket(Exception): - """ - Exception raised when a ticket can't be parsed. If we get far enough to - determine what the expected digest should have been, expected is set. - This should not be shown by default, but can be useful for debugging. - """ - - def __init__(self, msg, expected=None): - self.expected = expected - Exception.__init__(self, msg) - - -# this function licensed under the MIT license (stolen from Paste) -def parse_ticket(secret, ticket, ip, hashalg='md5'): - """ - Parse the ticket, returning (timestamp, userid, tokens, user_data). - - If the ticket cannot be parsed, a ``BadTicket`` exception will be raised - with an explanation. - """ - ticket = text_(ticket).strip('"') - digest_size = hashlib.new(hashalg).digest_size * 2 - digest = ticket[:digest_size] - try: - timestamp = int(ticket[digest_size : digest_size + 8], 16) - except ValueError as e: - raise BadTicket('Timestamp is not a hex integer: %s' % e) - try: - userid, data = ticket[digest_size + 8 :].split('!', 1) - except ValueError: - raise BadTicket('userid is not followed by !') - userid = unquote(userid) - if '!' in data: - tokens, user_data = data.split('!', 1) - else: # pragma: no cover (never generated) - # @@: Is this the right order? - tokens = '' - user_data = data - - expected = calculate_digest( - ip, timestamp, secret, userid, tokens, user_data, hashalg - ) - - # Avoid timing attacks (see - # http://seb.dbzteam.org/crypto/python-oauth-timing-hmac.pdf) - if strings_differ(expected, digest): - raise BadTicket( - 'Digest signature is not correct', expected=(expected, digest) - ) - - tokens = tokens.split(',') - - return (timestamp, userid, tokens, user_data) - - -# this function licensed under the MIT license (stolen from Paste) -def calculate_digest( - ip, timestamp, secret, userid, tokens, user_data, hashalg='md5' -): - secret = bytes_(secret, 'utf-8') - userid = bytes_(userid, 'utf-8') - tokens = bytes_(tokens, 'utf-8') - user_data = bytes_(user_data, 'utf-8') - hash_obj = hashlib.new(hashalg) - - # Check to see if this is an IPv6 address - if ':' in ip: - ip_timestamp = ip + str(int(timestamp)) - ip_timestamp = bytes_(ip_timestamp) - else: - # encode_ip_timestamp not required, left in for backwards compatibility - ip_timestamp = encode_ip_timestamp(ip, timestamp) - - hash_obj.update( - ip_timestamp + secret + userid + b'\0' + tokens + b'\0' + user_data - ) - digest = hash_obj.hexdigest() - hash_obj2 = hashlib.new(hashalg) - hash_obj2.update(bytes_(digest) + secret) - return hash_obj2.hexdigest() - - -# this function licensed under the MIT license (stolen from Paste) -def encode_ip_timestamp(ip, timestamp): - ip_chars = ''.join(map(chr, map(int, ip.split('.')))) - t = int(timestamp) - ts = ( - (t & 0xFF000000) >> 24, - (t & 0xFF0000) >> 16, - (t & 0xFF00) >> 8, - t & 0xFF, - ) - ts_chars = ''.join(map(chr, ts)) - return bytes_(ip_chars + ts_chars) - - -class AuthTktCookieHelper: - """ - A helper class used for constructing a :term:`security policy` with stores - the user identity in a signed cookie. - - Constructor Arguments - - ``secret`` - - 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. - - ``cookie_name`` - - Default: ``auth_tkt``. The cookie name used - (string). Optional. - - ``secure`` - - Default: ``False``. Only send the cookie back over a secure - conn. Optional. - - ``include_ip`` - - Default: ``False``. Make the requesting IP address part of - the authentication data in the cookie. Optional. - - For IPv6 this option is not recommended. The ``mod_auth_tkt`` - specification does not specify how to handle IPv6 addresses, so using - this option in combination with IPv6 addresses may cause an - incompatible cookie. It ties the authentication ticket to that - individual's IPv6 address. - - ``timeout`` - - Default: ``None``. Maximum number of seconds which a newly - issued ticket will be considered valid. After this amount of - time, the ticket will expire (effectively logging the user - out). If this value is ``None``, the ticket never expires. - Optional. - - ``reissue_time`` - - Default: ``None``. If this parameter is set, it represents the number - of seconds that must pass before an authentication token cookie is - automatically reissued as the result of a request which requires - authentication. The duration is measured as the number of seconds - since the last auth_tkt cookie was issued and 'now'. If this value is - ``0``, a new ticket cookie will be reissued on every request which - requires authentication. - - 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 - if so. However, such a configuration is not explicitly prevented. - - Optional. - - ``max_age`` - - Default: ``None``. The max age of the auth_tkt cookie, in - seconds. This differs from ``timeout`` inasmuch as ``timeout`` - represents the lifetime of the ticket contained in the cookie, - while this value represents the lifetime of the cookie itself. - When this value is set, the cookie's ``Max-Age`` and - ``Expires`` settings will be set, allowing the auth_tkt cookie - to last between browser sessions. It is typically nonsensical - to set this to a value that is lower than ``timeout`` or - ``reissue_time``, although it is not explicitly prevented. - Optional. - - ``path`` - - Default: ``/``. The path for which the auth_tkt cookie is valid. - May be desirable if the application only serves part of a domain. - Optional. - - ``http_only`` - - Default: ``False``. Hide cookie from JavaScript by setting the - HttpOnly flag. Not honored by all browsers. - Optional. - - ``wild_domain`` - - Default: ``True``. An auth_tkt cookie will be generated for the - wildcard domain. If your site is hosted as ``example.com`` this - will make the cookie available for sites underneath ``example.com`` - such as ``www.example.com``. - Optional. - - ``parent_domain`` - - Default: ``False``. An auth_tkt cookie will be generated for the - parent domain of the current site. For example if your site is - hosted under ``www.example.com`` a cookie will be generated for - ``.example.com``. This can be useful if you have multiple sites - sharing the same domain. This option supercedes the ``wild_domain`` - option. - Optional. - - ``domain`` - - Default: ``None``. If provided the auth_tkt cookie will only be - set for this domain. This option is not compatible with ``wild_domain`` - and ``parent_domain``. - Optional. - - ``hashalg`` - - Default: ``sha512`` (the literal string). - - Any hash algorithm supported by Python's ``hashlib.new()`` function - can be used as the ``hashalg``. - - Cookies generated by different instances of AuthTktAuthenticationPolicy - using different ``hashalg`` options are not compatible. Switching the - ``hashalg`` will imply that all existing users with a valid cookie will - be required to re-login. - - Optional. - - ``samesite`` - - Default: ``'Lax'``. The 'samesite' option of the session cookie. Set - the value to ``None`` to turn off the samesite option. - - This option is available as of :app:`Pyramid` 1.10. - """ - - parse_ticket = staticmethod(parse_ticket) # for tests - AuthTicket = AuthTicket # for tests - BadTicket = BadTicket # for tests - now = None # for tests - - userid_type_decoders = { - 'int': int, - 'unicode': lambda x: utf_8_decode(x)[0], # bw compat for old cookies - 'b64unicode': lambda x: utf_8_decode(b64decode(x))[0], - 'b64str': lambda x: b64decode(x), - } - - userid_type_encoders = { - int: ('int', str), - str: ('b64unicode', lambda x: b64encode(utf_8_encode(x)[0])), - bytes: ('b64str', lambda x: b64encode(x)), - } - - def __init__( - self, - secret, - cookie_name='auth_tkt', - secure=False, - include_ip=False, - timeout=None, - reissue_time=None, - max_age=None, - http_only=False, - path="/", - wild_domain=True, - hashalg='md5', - parent_domain=False, - domain=None, - samesite='Lax', - ): - self.cookie_profile = CookieProfile( - cookie_name=cookie_name, - secure=secure, - max_age=max_age, - httponly=http_only, - path=path, - serializer=SimpleSerializer(), - samesite=samesite, - ) - - self.secret = secret - self.cookie_name = cookie_name - self.secure = secure - self.include_ip = include_ip - self.timeout = timeout if timeout is None else int(timeout) - self.reissue_time = ( - reissue_time if reissue_time is None else int(reissue_time) - ) - self.max_age = max_age if max_age is None else int(max_age) - self.wild_domain = wild_domain - self.parent_domain = parent_domain - self.domain = domain - self.hashalg = hashalg - - def _get_cookies(self, request, value, max_age=None): - cur_domain = request.domain - - domains = [] - if self.domain: - domains.append(self.domain) - else: - if self.parent_domain and cur_domain.count('.') > 1: - domains.append('.' + cur_domain.split('.', 1)[1]) - else: - domains.append(None) - domains.append(cur_domain) - if self.wild_domain: - domains.append('.' + cur_domain) - - profile = self.cookie_profile(request) - - kw = {} - kw['domains'] = domains - if max_age is not None: - kw['max_age'] = max_age - - headers = profile.get_headers(value, **kw) - return headers - - def identify(self, request): - """ Return a dictionary with authentication information, or ``None`` - if no valid auth_tkt is attached to ``request``""" - environ = request.environ - cookie = request.cookies.get(self.cookie_name) - - if cookie is None: - return None - - if self.include_ip: - remote_addr = environ['REMOTE_ADDR'] - else: - remote_addr = '0.0.0.0' - - try: - timestamp, userid, tokens, user_data = self.parse_ticket( - self.secret, cookie, remote_addr, self.hashalg - ) - except self.BadTicket: - return None - - now = self.now # service tests - - if now is None: - now = time_mod.time() - - if self.timeout and ((timestamp + self.timeout) < now): - # the auth_tkt data has expired - return None - - userid_typename = 'userid_type:' - user_data_info = user_data.split('|') - for datum in filter(None, user_data_info): - if datum.startswith(userid_typename): - userid_type = datum[len(userid_typename) :] - decoder = self.userid_type_decoders.get(userid_type) - if decoder: - userid = decoder(userid) - - reissue = self.reissue_time is not None - - if reissue and not hasattr(request, '_authtkt_reissued'): - if (now - timestamp) > self.reissue_time: - # See https://github.com/Pylons/pyramid/issues#issue/108 - tokens = list(filter(None, tokens)) - headers = self.remember( - request, userid, max_age=self.max_age, tokens=tokens - ) - - def reissue_authtkt(request, response): - if not hasattr(request, '_authtkt_reissue_revoked'): - for k, v in headers: - response.headerlist.append((k, v)) - - request.add_response_callback(reissue_authtkt) - request._authtkt_reissued = True - - environ['REMOTE_USER_TOKENS'] = tokens - environ['REMOTE_USER_DATA'] = user_data - environ['AUTH_TYPE'] = 'cookie' - - identity = {} - identity['timestamp'] = timestamp - identity['userid'] = userid - identity['tokens'] = tokens - identity['userdata'] = user_data - return identity - - def forget(self, request): - """ Return a set of expires Set-Cookie headers, which will destroy - any existing auth_tkt cookie when attached to a response""" - request._authtkt_reissue_revoked = True - return self._get_cookies(request, None) - - def remember(self, request, userid, max_age=None, tokens=()): - """ Return a set of Set-Cookie headers; when set into a response, - these headers will represent a valid authentication ticket. - - ``max_age`` - The max age of the auth_tkt cookie, in seconds. When this value is - set, the cookie's ``Max-Age`` and ``Expires`` settings will be set, - allowing the auth_tkt cookie to last between browser sessions. If - this value is ``None``, the ``max_age`` value provided to the - helper itself will be used as the ``max_age`` value. Default: - ``None``. - - ``tokens`` - A sequence of strings that will be placed into the auth_tkt tokens - field. Each string in the sequence must be of the Python ``str`` - type and must match the regex ``^[A-Za-z][A-Za-z0-9+_-]*$``. - Tokens are available in the returned identity when an auth_tkt is - found in the request and unpacked. Default: ``()``. - """ - max_age = self.max_age if max_age is None else int(max_age) - - environ = request.environ - - if self.include_ip: - remote_addr = environ['REMOTE_ADDR'] - else: - remote_addr = '0.0.0.0' - - user_data = '' - - encoding_data = self.userid_type_encoders.get(type(userid)) - - if encoding_data: - encoding, encoder = encoding_data - else: - warnings.warn( - "userid is of type {}, and is not supported by the " - "AuthTktAuthenticationPolicy. Explicitly converting to string " - "and storing as base64. Subsequent requests will receive a " - "string as the userid, it will not be decoded back to the " - "type provided.".format(type(userid)), - RuntimeWarning, - ) - encoding, encoder = self.userid_type_encoders.get(str) - userid = str(userid) - - userid = encoder(userid) - user_data = 'userid_type:%s' % encoding - - new_tokens = [] - for token in tokens: - if isinstance(token, str): - try: - token = ascii_(token) - except UnicodeEncodeError: - raise ValueError("Invalid token %r" % (token,)) - if not (isinstance(token, str) and VALID_TOKEN.match(token)): - raise ValueError("Invalid token %r" % (token,)) - new_tokens.append(token) - tokens = tuple(new_tokens) - - if hasattr(request, '_authtkt_reissued'): - request._authtkt_reissue_revoked = True - - ticket = self.AuthTicket( - self.secret, - userid, - remote_addr, - tokens=tokens, - user_data=user_data, - cookie_name=self.cookie_name, - secure=self.secure, - hashalg=self.hashalg, - ) - - cookie_value = ticket.cookie_value() - return self._get_cookies(request, cookie_value, max_age) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 89cf9866d..8671eba05 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,7 +1,8 @@ +from http.cookies import SimpleCookie import unittest import warnings from pyramid import testing -from pyramid.util import bytes_ +from pyramid.util import text_, bytes_ class TestCallbackAuthenticationPolicyDebugging(unittest.TestCase): @@ -663,6 +664,926 @@ class TestAuthTktAuthenticationPolicy(unittest.TestCase): verifyObject(IAuthenticationPolicy, self._makeOne(None, None)) +class TestAuthTktCookieHelper(unittest.TestCase): + def _getTargetClass(self): + from pyramid.authentication import AuthTktCookieHelper + + return AuthTktCookieHelper + + def _makeOne(self, *arg, **kw): + helper = self._getTargetClass()(*arg, **kw) + # laziness after moving auth_tkt classes and funcs into + # authentication module + auth_tkt = DummyAuthTktModule() + helper.auth_tkt = auth_tkt + helper.AuthTicket = auth_tkt.AuthTicket + helper.parse_ticket = auth_tkt.parse_ticket + helper.BadTicket = auth_tkt.BadTicket + return helper + + def _makeRequest(self, cookie=None, ipv6=False): + environ = {'wsgi.version': (1, 0)} + + if ipv6 is False: + environ['REMOTE_ADDR'] = '1.1.1.1' + else: + environ['REMOTE_ADDR'] = '::1' + environ['SERVER_NAME'] = 'localhost' + return DummyRequest(environ, cookie=cookie) + + def _cookieValue(self, cookie): + items = cookie.value.split('/') + D = {} + for item in items: + k, v = item.split('=', 1) + D[k] = v + return D + + def _parseHeaders(self, headers): + return [self._parseHeader(header) for header in headers] + + def _parseHeader(self, header): + cookie = self._parseCookie(header[1]) + return cookie + + def _parseCookie(self, cookie): + cookies = SimpleCookie() + cookies.load(cookie) + return cookies.get('auth_tkt') + + def test_init_cookie_str_reissue_invalid(self): + self.assertRaises( + ValueError, self._makeOne, 'secret', reissue_time='invalid value' + ) + + def test_init_cookie_str_timeout_invalid(self): + self.assertRaises( + ValueError, self._makeOne, 'secret', timeout='invalid value' + ) + + def test_init_cookie_str_max_age_invalid(self): + self.assertRaises( + ValueError, self._makeOne, 'secret', max_age='invalid value' + ) + + def test_identify_nocookie(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.identify(request) + self.assertEqual(result, None) + + def test_identify_cookie_value_is_None(self): + helper = self._makeOne('secret') + request = self._makeRequest(None) + result = helper.identify(request) + self.assertEqual(result, None) + + def test_identify_good_cookie_include_ip(self): + helper = self._makeOne('secret', include_ip=True) + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 'userid') + self.assertEqual(result['userdata'], '') + self.assertEqual(result['timestamp'], 0) + self.assertEqual(helper.auth_tkt.value, 'ticket') + self.assertEqual(helper.auth_tkt.remote_addr, '1.1.1.1') + self.assertEqual(helper.auth_tkt.secret, 'secret') + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], '') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_include_ipv6(self): + helper = self._makeOne('secret', include_ip=True) + request = self._makeRequest('ticket', ipv6=True) + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 'userid') + self.assertEqual(result['userdata'], '') + self.assertEqual(result['timestamp'], 0) + self.assertEqual(helper.auth_tkt.value, 'ticket') + self.assertEqual(helper.auth_tkt.remote_addr, '::1') + self.assertEqual(helper.auth_tkt.secret, 'secret') + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], '') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_dont_include_ip(self): + helper = self._makeOne('secret', include_ip=False) + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 'userid') + self.assertEqual(result['userdata'], '') + self.assertEqual(result['timestamp'], 0) + self.assertEqual(helper.auth_tkt.value, 'ticket') + self.assertEqual(helper.auth_tkt.remote_addr, '0.0.0.0') + self.assertEqual(helper.auth_tkt.secret, 'secret') + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], '') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_int_useridtype(self): + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = '1' + helper.auth_tkt.user_data = 'userid_type:int' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 1) + self.assertEqual(result['userdata'], 'userid_type:int') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:int') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_nonuseridtype_user_data(self): + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = '1' + helper.auth_tkt.user_data = 'bogus:int' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], '1') + self.assertEqual(result['userdata'], 'bogus:int') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'bogus:int') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_unknown_useridtype(self): + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = 'abc' + helper.auth_tkt.user_data = 'userid_type:unknown' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], 'abc') + self.assertEqual(result['userdata'], 'userid_type:unknown') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:unknown') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_b64str_useridtype(self): + from base64 import b64encode + + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = b64encode(b'encoded').strip() + helper.auth_tkt.user_data = 'userid_type:b64str' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], b'encoded') + self.assertEqual(result['userdata'], 'userid_type:b64str') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:b64str') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_good_cookie_b64unicode_useridtype(self): + from base64 import b64encode + + helper = self._makeOne('secret', include_ip=False) + helper.auth_tkt.userid = b64encode(b'\xc3\xa9ncoded').strip() + helper.auth_tkt.user_data = 'userid_type:b64unicode' + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(len(result), 4) + self.assertEqual(result['tokens'], ()) + self.assertEqual(result['userid'], text_(b'\xc3\xa9ncoded', 'utf-8')) + self.assertEqual(result['userdata'], 'userid_type:b64unicode') + self.assertEqual(result['timestamp'], 0) + environ = request.environ + self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) + self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:b64unicode') + self.assertEqual(environ['AUTH_TYPE'], 'cookie') + + def test_identify_bad_cookie(self): + helper = self._makeOne('secret', include_ip=True) + helper.auth_tkt.parse_raise = True + request = self._makeRequest('ticket') + result = helper.identify(request) + self.assertEqual(result, None) + + def test_identify_cookie_timeout(self): + helper = self._makeOne('secret', timeout=1) + self.assertEqual(helper.timeout, 1) + + def test_identify_cookie_str_timeout(self): + helper = self._makeOne('secret', timeout='1') + self.assertEqual(helper.timeout, 1) + + def test_identify_cookie_timeout_aged(self): + import time + + helper = self._makeOne('secret', timeout=10) + now = time.time() + helper.auth_tkt.timestamp = now - 1 + helper.now = now + 10 + helper.auth_tkt.tokens = (text_('a'),) + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertFalse(result) + + def test_identify_cookie_reissue(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + helper.auth_tkt.tokens = (text_('a'),) + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](request, response) + self.assertEqual(len(response.headerlist), 3) + self.assertEqual(response.headerlist[0][0], 'Set-Cookie') + + def test_identify_cookie_str_reissue(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time='0') + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + helper.auth_tkt.tokens = (text_('a'),) + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](request, response) + self.assertEqual(len(response.headerlist), 3) + self.assertEqual(response.headerlist[0][0], 'Set-Cookie') + + def test_identify_cookie_reissue_already_reissued_this_request(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + request._authtkt_reissued = True + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 0) + + def test_identify_cookie_reissue_notyet(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=10) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 0) + + def test_identify_cookie_reissue_revoked_by_forget(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + result = helper.forget(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](request, response) + self.assertEqual(len(response.headerlist), 0) + + def test_identify_cookie_reissue_revoked_by_remember(self): + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + result = helper.remember(request, 'bob') + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](request, response) + self.assertEqual(len(response.headerlist), 0) + + def test_identify_cookie_reissue_with_tokens_default(self): + # see https://github.com/Pylons/pyramid/issues#issue/108 + import time + + helper = self._makeOne('secret', timeout=10, reissue_time=0) + auth_tkt = DummyAuthTktModule(tokens=['']) + helper.auth_tkt = auth_tkt + helper.AuthTicket = auth_tkt.AuthTicket + helper.parse_ticket = auth_tkt.parse_ticket + helper.BadTicket = auth_tkt.BadTicket + now = time.time() + helper.auth_tkt.timestamp = now + helper.now = now + 1 + request = self._makeRequest('bogus') + result = helper.identify(request) + self.assertTrue(result) + self.assertEqual(len(request.callbacks), 1) + response = DummyResponse() + request.callbacks[0](None, response) + self.assertEqual(len(response.headerlist), 3) + self.assertEqual(response.headerlist[0][0], 'Set-Cookie') + self.assertTrue("/tokens=/" in response.headerlist[0][1]) + + def test_remember(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue( + result[2][1].endswith('; Domain=.localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_nondefault_samesite(self): + helper = self._makeOne('secret', samesite='Strict') + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Strict')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith( + '; Domain=localhost; Path=/; SameSite=Strict' + ) + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue( + result[2][1].endswith( + '; Domain=.localhost; Path=/; SameSite=Strict' + ) + ) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_None_samesite(self): + helper = self._makeOne('secret', samesite=None) + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/')) # no samesite + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue(result[1][1].endswith('; Domain=localhost; Path=/')) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue(result[2][1].endswith('; Domain=.localhost; Path=/')) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_include_ip(self): + helper = self._makeOne('secret', include_ip=True) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue( + result[2][1].endswith('; Domain=.localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_path(self): + helper = self._makeOne( + 'secret', include_ip=True, path="/cgi-bin/app.cgi/" + ) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue( + result[0][1].endswith('; Path=/cgi-bin/app.cgi/; SameSite=Lax') + ) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith( + '; Domain=localhost; Path=/cgi-bin/app.cgi/; SameSite=Lax' + ) + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue( + result[2][1].endswith( + '; Domain=.localhost; Path=/cgi-bin/app.cgi/; SameSite=Lax' + ) + ) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_http_only(self): + helper = self._makeOne('secret', include_ip=True, http_only=True) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; HttpOnly; SameSite=Lax')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue('; HttpOnly' in result[1][1]) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue('; HttpOnly' in result[2][1]) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_secure(self): + helper = self._makeOne('secret', include_ip=True, secure=True) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue('; secure' in result[0][1]) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue('; secure' in result[1][1]) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue('; secure' in result[2][1]) + self.assertTrue(result[2][1].startswith('auth_tkt=')) + + def test_remember_wild_domain_disabled(self): + helper = self._makeOne('secret', wild_domain=False) + request = self._makeRequest() + result = helper.remember(request, 'other') + self.assertEqual(len(result), 2) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue( + result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') + ) + self.assertTrue(result[1][1].startswith('auth_tkt=')) + + def test_remember_parent_domain(self): + helper = self._makeOne('secret', parent_domain=True) + request = self._makeRequest() + request.domain = 'www.example.com' + result = helper.remember(request, 'other') + self.assertEqual(len(result), 1) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue( + result[0][1].endswith( + '; Domain=.example.com; Path=/; SameSite=Lax' + ) + ) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + def test_remember_parent_domain_supercedes_wild_domain(self): + helper = self._makeOne('secret', parent_domain=True, wild_domain=True) + request = self._makeRequest() + request.domain = 'www.example.com' + result = helper.remember(request, 'other') + self.assertEqual(len(result), 1) + self.assertTrue( + result[0][1].endswith( + '; Domain=.example.com; Path=/; SameSite=Lax' + ) + ) + + def test_remember_explicit_domain(self): + helper = self._makeOne('secret', domain='pyramid.bazinga') + request = self._makeRequest() + request.domain = 'www.example.com' + result = helper.remember(request, 'other') + self.assertEqual(len(result), 1) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue( + result[0][1].endswith( + '; Domain=pyramid.bazinga; Path=/; SameSite=Lax' + ) + ) + self.assertTrue(result[0][1].startswith('auth_tkt=')) + + def test_remember_domain_supercedes_parent_and_wild_domain(self): + helper = self._makeOne( + 'secret', + domain='pyramid.bazinga', + parent_domain=True, + wild_domain=True, + ) + request = self._makeRequest() + request.domain = 'www.example.com' + result = helper.remember(request, 'other') + self.assertEqual(len(result), 1) + self.assertTrue( + result[0][1].endswith( + '; Domain=pyramid.bazinga; Path=/; SameSite=Lax' + ) + ) + + def test_remember_binary_userid(self): + import base64 + + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, b'userid') + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + val = self._cookieValue(values[0]) + self.assertEqual( + val['userid'], text_(base64.b64encode(b'userid').strip()) + ) + self.assertEqual(val['user_data'], 'userid_type:b64str') + + def test_remember_int_userid(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 1) + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + val = self._cookieValue(values[0]) + self.assertEqual(val['userid'], '1') + self.assertEqual(val['user_data'], 'userid_type:int') + + def test_remember_unicode_userid(self): + import base64 + + helper = self._makeOne('secret') + request = self._makeRequest() + userid = text_(b'\xc2\xa9', 'utf-8') + result = helper.remember(request, userid) + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + val = self._cookieValue(values[0]) + self.assertEqual( + val['userid'], text_(base64.b64encode(userid.encode('utf-8'))) + ) + self.assertEqual(val['user_data'], 'userid_type:b64unicode') + + def test_remember_insane_userid(self): + helper = self._makeOne('secret') + request = self._makeRequest() + userid = object() + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always', RuntimeWarning) + result = helper.remember(request, userid) + self.assertTrue(str(w[-1].message).startswith('userid is of type')) + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + value = values[0] + self.assertTrue('userid' in value.value) + + def test_remember_max_age(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'userid', max_age=500) + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + + self.assertEqual(values[0]['max-age'], '500') + self.assertTrue(values[0]['expires']) + + def test_remember_str_max_age(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'userid', max_age='500') + values = self._parseHeaders(result) + self.assertEqual(len(result), 3) + + self.assertEqual(values[0]['max-age'], '500') + self.assertTrue(values[0]['expires']) + + def test_remember_str_max_age_invalid(self): + helper = self._makeOne('secret') + request = self._makeRequest() + self.assertRaises( + ValueError, + helper.remember, + request, + 'userid', + max_age='invalid value', + ) + + def test_remember_tokens(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'other', tokens=('foo', 'bar')) + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + self.assertTrue("/tokens=foo|bar/" in result[0][1]) + + self.assertEqual(result[1][0], 'Set-Cookie') + self.assertTrue("/tokens=foo|bar/" in result[1][1]) + + self.assertEqual(result[2][0], 'Set-Cookie') + self.assertTrue("/tokens=foo|bar/" in result[2][1]) + + def test_remember_samesite_nondefault(self): + helper = self._makeOne('secret', samesite='Strict') + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + cookieval = result[0][1] + self.assertTrue( + 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + self.assertEqual(result[1][0], 'Set-Cookie') + cookieval = result[1][1] + self.assertTrue( + 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + self.assertEqual(result[2][0], 'Set-Cookie') + cookieval = result[2][1] + self.assertTrue( + 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + def test_remember_samesite_default(self): + helper = self._makeOne('secret') + request = self._makeRequest() + result = helper.remember(request, 'userid') + self.assertEqual(len(result), 3) + + self.assertEqual(result[0][0], 'Set-Cookie') + cookieval = result[0][1] + self.assertTrue( + 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + self.assertEqual(result[1][0], 'Set-Cookie') + cookieval = result[1][1] + self.assertTrue( + 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + self.assertEqual(result[2][0], 'Set-Cookie') + cookieval = result[2][1] + self.assertTrue( + 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], + cookieval, + ) + + def test_remember_unicode_but_ascii_token(self): + helper = self._makeOne('secret') + request = self._makeRequest() + la = text_(b'foo', 'utf-8') + result = helper.remember(request, 'other', tokens=(la,)) + # tokens must be str type on both Python 2 and 3 + self.assertTrue("/tokens=foo/" in result[0][1]) + + def test_remember_nonascii_token(self): + helper = self._makeOne('secret') + request = self._makeRequest() + la = text_(b'La Pe\xc3\xb1a', 'utf-8') + self.assertRaises( + ValueError, helper.remember, request, 'other', tokens=(la,) + ) + + def test_remember_invalid_token_format(self): + helper = self._makeOne('secret') + request = self._makeRequest() + self.assertRaises( + ValueError, helper.remember, request, 'other', tokens=('foo bar',) + ) + self.assertRaises( + ValueError, helper.remember, request, 'other', tokens=('1bar',) + ) + + def test_forget(self): + helper = self._makeOne('secret') + request = self._makeRequest() + headers = helper.forget(request) + self.assertEqual(len(headers), 3) + name, value = headers[0] + self.assertEqual(name, 'Set-Cookie') + self.assertEqual( + value, + 'auth_tkt=; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', + ) + name, value = headers[1] + self.assertEqual(name, 'Set-Cookie') + self.assertEqual( + value, + 'auth_tkt=; Domain=localhost; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', + ) + name, value = headers[2] + self.assertEqual(name, 'Set-Cookie') + self.assertEqual( + value, + 'auth_tkt=; Domain=.localhost; Max-Age=0; Path=/; ' + 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', + ) + + +class TestAuthTicket(unittest.TestCase): + def _makeOne(self, *arg, **kw): + from pyramid.authentication import AuthTicket + + return AuthTicket(*arg, **kw) + + def test_ctor_with_tokens(self): + ticket = self._makeOne('secret', 'userid', 'ip', tokens=('a', 'b')) + self.assertEqual(ticket.tokens, 'a,b') + + def test_ctor_with_time(self): + ticket = self._makeOne('secret', 'userid', 'ip', time='time') + self.assertEqual(ticket.time, 'time') + + def test_digest(self): + ticket = self._makeOne('secret', 'userid', '0.0.0.0', time=10) + result = ticket.digest() + self.assertEqual(result, '126fd6224912187ee9ffa80e0b81420c') + + def test_digest_sha512(self): + ticket = self._makeOne( + 'secret', 'userid', '0.0.0.0', time=10, hashalg='sha512' + ) + result = ticket.digest() + self.assertEqual( + result, + '74770b2e0d5b1a54c2a466ec567a40f7d7823576aa49' + '3c65fc3445e9b44097f4a80410319ef8cb256a2e60b9' + 'c2002e48a9e33a3e8ee4379352c04ef96d2cb278', + ) + + def test_cookie_value(self): + ticket = self._makeOne( + 'secret', 'userid', '0.0.0.0', time=10, tokens=('a', 'b') + ) + result = ticket.cookie_value() + self.assertEqual( + result, '66f9cc3e423dc57c91df696cf3d1f0d80000000auserid!a,b!' + ) + + def test_ipv4(self): + ticket = self._makeOne( + 'secret', 'userid', '198.51.100.1', time=10, hashalg='sha256' + ) + result = ticket.cookie_value() + self.assertEqual( + result, + 'b3e7156db4f8abde4439c4a6499a0668f9e7ffd7fa27b' + '798400ecdade8d76c530000000auserid!', + ) + + def test_ipv6(self): + ticket = self._makeOne( + 'secret', 'userid', '2001:db8::1', time=10, hashalg='sha256' + ) + result = ticket.cookie_value() + self.assertEqual( + result, + 'd025b601a0f12ca6d008aa35ff3a22b7d8f3d1c1456c8' + '5becf8760cd7a2fa4910000000auserid!', + ) + + +class TestBadTicket(unittest.TestCase): + def _makeOne(self, msg, expected=None): + from pyramid.authentication import BadTicket + + return BadTicket(msg, expected) + + def test_it(self): + exc = self._makeOne('msg', expected=True) + self.assertEqual(exc.expected, True) + self.assertTrue(isinstance(exc, Exception)) + + +class Test_parse_ticket(unittest.TestCase): + def _callFUT(self, secret, ticket, ip, hashalg='md5'): + from pyramid.authentication import parse_ticket + + return parse_ticket(secret, ticket, ip, hashalg) + + def _assertRaisesBadTicket(self, secret, ticket, ip, hashalg='md5'): + from pyramid.authentication import BadTicket + + self.assertRaises( + BadTicket, self._callFUT, secret, ticket, ip, hashalg + ) + + def test_bad_timestamp(self): + ticket = 'x' * 64 + self._assertRaisesBadTicket('secret', ticket, 'ip') + + def test_bad_userid_or_data(self): + ticket = 'x' * 32 + '11111111' + 'x' * 10 + self._assertRaisesBadTicket('secret', ticket, 'ip') + + def test_digest_sig_incorrect(self): + ticket = 'x' * 32 + '11111111' + 'a!b!c' + self._assertRaisesBadTicket('secret', ticket, '0.0.0.0') + + def test_correct_with_user_data(self): + ticket = text_('66f9cc3e423dc57c91df696cf3d1f0d80000000auserid!a,b!') + result = self._callFUT('secret', ticket, '0.0.0.0') + self.assertEqual(result, (10, 'userid', ['a', 'b'], '')) + + def test_correct_with_user_data_sha512(self): + ticket = text_( + '7d947cdef99bad55f8e3382a8bd089bb9dd0547f7925b7d189adc1' + '160cab0ec0e6888faa41eba641a18522b26f19109f3ffafb769767' + 'ba8a26d02aaeae56599a0000000auserid!a,b!' + ) + result = self._callFUT('secret', ticket, '0.0.0.0', 'sha512') + self.assertEqual(result, (10, 'userid', ['a', 'b'], '')) + + def test_ipv4(self): + ticket = text_( + 'b3e7156db4f8abde4439c4a6499a0668f9e7ffd7fa27b798400ecd' + 'ade8d76c530000000auserid!' + ) + result = self._callFUT('secret', ticket, '198.51.100.1', 'sha256') + self.assertEqual(result, (10, 'userid', [''], '')) + + def test_ipv6(self): + ticket = text_( + 'd025b601a0f12ca6d008aa35ff3a22b7d8f3d1c1456c85becf8760' + 'cd7a2fa4910000000auserid!' + ) + result = self._callFUT('secret', ticket, '2001:db8::1', 'sha256') + self.assertEqual(result, (10, 'userid', [''], '')) + + class TestSessionAuthenticationPolicy(unittest.TestCase): def _getTargetClass(self): from pyramid.authentication import SessionAuthenticationPolicy @@ -990,6 +1911,14 @@ class DummyContext: pass +class DummyCookies(object): + def __init__(self, cookie): + self.cookie = cookie + + def get(self, name): + return self.cookie + + class DummyRequest: domain = 'localhost' @@ -998,6 +1927,10 @@ class DummyRequest: self.session = session or {} self.registry = registry self.callbacks = [] + self.cookies = DummyCookies(cookie) + + def add_response_callback(self, callback): + self.callbacks.append(callback) class DummyWhoPlugin: @@ -1021,3 +1954,68 @@ class DummyCookieHelper: def forget(self, *arg): return [] + + +class DummyAuthTktModule(object): + def __init__( + self, + timestamp=0, + userid='userid', + tokens=(), + user_data='', + parse_raise=False, + hashalg="md5", + ): + self.timestamp = timestamp + self.userid = userid + self.tokens = tokens + self.user_data = user_data + self.parse_raise = parse_raise + self.hashalg = hashalg + + def parse_ticket(secret, value, remote_addr, hashalg): + self.secret = secret + self.value = value + self.remote_addr = remote_addr + if self.parse_raise: + raise self.BadTicket() + return self.timestamp, self.userid, self.tokens, self.user_data + + self.parse_ticket = parse_ticket + + class AuthTicket(object): + def __init__(self, secret, userid, remote_addr, **kw): + self.secret = secret + self.userid = userid + self.remote_addr = remote_addr + self.kw = kw + + def cookie_value(self): + result = { + 'secret': self.secret, + 'userid': self.userid, + 'remote_addr': self.remote_addr, + } + result.update(self.kw) + tokens = result.pop('tokens', None) + if tokens is not None: + tokens = '|'.join(tokens) + result['tokens'] = tokens + items = sorted(result.items()) + new_items = [] + for k, v in items: + if isinstance(v, bytes): + v = text_(v) + new_items.append((k, v)) + result = '/'.join(['%s=%s' % (k, v) for k, v in new_items]) + return result + + self.AuthTicket = AuthTicket + + class BadTicket(Exception): + pass + + +class DummyResponse: + def __init__(self): + self.headerlist = [] diff --git a/tests/test_security.py b/tests/test_security.py index 97aec42c6..f14159156 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -1,9 +1,6 @@ import unittest -import warnings -from http.cookies import SimpleCookie from pyramid import testing -from pyramid.util import text_ class TestAllPermissionsList(unittest.TestCase): @@ -940,1008 +937,3 @@ class TestSessionAuthenticationHelper(unittest.TestCase): result = helper.forget(request) self.assertEqual(request.session.get('userid'), None) self.assertEqual(result, []) - - -class TestAuthTicket(unittest.TestCase): - def _makeOne(self, *arg, **kw): - from pyramid.security import AuthTicket - - return AuthTicket(*arg, **kw) - - def test_ctor_with_tokens(self): - ticket = self._makeOne('secret', 'userid', 'ip', tokens=('a', 'b')) - self.assertEqual(ticket.tokens, 'a,b') - - def test_ctor_with_time(self): - ticket = self._makeOne('secret', 'userid', 'ip', time='time') - self.assertEqual(ticket.time, 'time') - - def test_digest(self): - ticket = self._makeOne('secret', 'userid', '0.0.0.0', time=10) - result = ticket.digest() - self.assertEqual(result, '126fd6224912187ee9ffa80e0b81420c') - - def test_digest_sha512(self): - ticket = self._makeOne( - 'secret', 'userid', '0.0.0.0', time=10, hashalg='sha512' - ) - result = ticket.digest() - self.assertEqual( - result, - '74770b2e0d5b1a54c2a466ec567a40f7d7823576aa49' - '3c65fc3445e9b44097f4a80410319ef8cb256a2e60b9' - 'c2002e48a9e33a3e8ee4379352c04ef96d2cb278', - ) - - def test_cookie_value(self): - ticket = self._makeOne( - 'secret', 'userid', '0.0.0.0', time=10, tokens=('a', 'b') - ) - result = ticket.cookie_value() - self.assertEqual( - result, '66f9cc3e423dc57c91df696cf3d1f0d80000000auserid!a,b!' - ) - - def test_ipv4(self): - ticket = self._makeOne( - 'secret', 'userid', '198.51.100.1', time=10, hashalg='sha256' - ) - result = ticket.cookie_value() - self.assertEqual( - result, - 'b3e7156db4f8abde4439c4a6499a0668f9e7ffd7fa27b' - '798400ecdade8d76c530000000auserid!', - ) - - def test_ipv6(self): - ticket = self._makeOne( - 'secret', 'userid', '2001:db8::1', time=10, hashalg='sha256' - ) - result = ticket.cookie_value() - self.assertEqual( - result, - 'd025b601a0f12ca6d008aa35ff3a22b7d8f3d1c1456c8' - '5becf8760cd7a2fa4910000000auserid!', - ) - - -class TestBadTicket(unittest.TestCase): - def _makeOne(self, msg, expected=None): - from pyramid.security import BadTicket - - return BadTicket(msg, expected) - - def test_it(self): - exc = self._makeOne('msg', expected=True) - self.assertEqual(exc.expected, True) - self.assertTrue(isinstance(exc, Exception)) - - -class Test_parse_ticket(unittest.TestCase): - def _callFUT(self, secret, ticket, ip, hashalg='md5'): - from pyramid.security import parse_ticket - - return parse_ticket(secret, ticket, ip, hashalg) - - def _assertRaisesBadTicket(self, secret, ticket, ip, hashalg='md5'): - from pyramid.security import BadTicket - - self.assertRaises( - BadTicket, self._callFUT, secret, ticket, ip, hashalg - ) - - def test_bad_timestamp(self): - ticket = 'x' * 64 - self._assertRaisesBadTicket('secret', ticket, 'ip') - - def test_bad_userid_or_data(self): - ticket = 'x' * 32 + '11111111' + 'x' * 10 - self._assertRaisesBadTicket('secret', ticket, 'ip') - - def test_digest_sig_incorrect(self): - ticket = 'x' * 32 + '11111111' + 'a!b!c' - self._assertRaisesBadTicket('secret', ticket, '0.0.0.0') - - def test_correct_with_user_data(self): - ticket = text_('66f9cc3e423dc57c91df696cf3d1f0d80000000auserid!a,b!') - result = self._callFUT('secret', ticket, '0.0.0.0') - self.assertEqual(result, (10, 'userid', ['a', 'b'], '')) - - def test_correct_with_user_data_sha512(self): - ticket = text_( - '7d947cdef99bad55f8e3382a8bd089bb9dd0547f7925b7d189adc1' - '160cab0ec0e6888faa41eba641a18522b26f19109f3ffafb769767' - 'ba8a26d02aaeae56599a0000000auserid!a,b!' - ) - result = self._callFUT('secret', ticket, '0.0.0.0', 'sha512') - self.assertEqual(result, (10, 'userid', ['a', 'b'], '')) - - def test_ipv4(self): - ticket = text_( - 'b3e7156db4f8abde4439c4a6499a0668f9e7ffd7fa27b798400ecd' - 'ade8d76c530000000auserid!' - ) - result = self._callFUT('secret', ticket, '198.51.100.1', 'sha256') - self.assertEqual(result, (10, 'userid', [''], '')) - - def test_ipv6(self): - ticket = text_( - 'd025b601a0f12ca6d008aa35ff3a22b7d8f3d1c1456c85becf8760' - 'cd7a2fa4910000000auserid!' - ) - result = self._callFUT('secret', ticket, '2001:db8::1', 'sha256') - self.assertEqual(result, (10, 'userid', [''], '')) - - -class TestAuthTktCookieHelper(unittest.TestCase): - def _getTargetClass(self): - from pyramid.security import AuthTktCookieHelper - - return AuthTktCookieHelper - - def _makeOne(self, *arg, **kw): - helper = self._getTargetClass()(*arg, **kw) - auth_tkt = DummyAuthTktModule() - helper.auth_tkt = auth_tkt - helper.AuthTicket = auth_tkt.AuthTicket - helper.parse_ticket = auth_tkt.parse_ticket - helper.BadTicket = auth_tkt.BadTicket - return helper - - def _makeRequest(self, cookie=None, ipv6=False): - environ = {'wsgi.version': (1, 0)} - - if ipv6 is False: - environ['REMOTE_ADDR'] = '1.1.1.1' - else: - environ['REMOTE_ADDR'] = '::1' - environ['SERVER_NAME'] = 'localhost' - return DummyRequest(environ, cookie=cookie) - - def _cookieValue(self, cookie): - items = cookie.value.split('/') - D = {} - for item in items: - k, v = item.split('=', 1) - D[k] = v - return D - - def _parseHeaders(self, headers): - return [self._parseHeader(header) for header in headers] - - def _parseHeader(self, header): - cookie = self._parseCookie(header[1]) - return cookie - - def _parseCookie(self, cookie): - cookies = SimpleCookie() - cookies.load(cookie) - return cookies.get('auth_tkt') - - def test_init_cookie_str_reissue_invalid(self): - self.assertRaises( - ValueError, self._makeOne, 'secret', reissue_time='invalid value' - ) - - def test_init_cookie_str_timeout_invalid(self): - self.assertRaises( - ValueError, self._makeOne, 'secret', timeout='invalid value' - ) - - def test_init_cookie_str_max_age_invalid(self): - self.assertRaises( - ValueError, self._makeOne, 'secret', max_age='invalid value' - ) - - def test_identify_nocookie(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.identify(request) - self.assertEqual(result, None) - - def test_identify_cookie_value_is_None(self): - helper = self._makeOne('secret') - request = self._makeRequest(None) - result = helper.identify(request) - self.assertEqual(result, None) - - def test_identify_good_cookie_include_ip(self): - helper = self._makeOne('secret', include_ip=True) - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 'userid') - self.assertEqual(result['userdata'], '') - self.assertEqual(result['timestamp'], 0) - self.assertEqual(helper.auth_tkt.value, 'ticket') - self.assertEqual(helper.auth_tkt.remote_addr, '1.1.1.1') - self.assertEqual(helper.auth_tkt.secret, 'secret') - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], '') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_include_ipv6(self): - helper = self._makeOne('secret', include_ip=True) - request = self._makeRequest('ticket', ipv6=True) - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 'userid') - self.assertEqual(result['userdata'], '') - self.assertEqual(result['timestamp'], 0) - self.assertEqual(helper.auth_tkt.value, 'ticket') - self.assertEqual(helper.auth_tkt.remote_addr, '::1') - self.assertEqual(helper.auth_tkt.secret, 'secret') - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], '') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_dont_include_ip(self): - helper = self._makeOne('secret', include_ip=False) - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 'userid') - self.assertEqual(result['userdata'], '') - self.assertEqual(result['timestamp'], 0) - self.assertEqual(helper.auth_tkt.value, 'ticket') - self.assertEqual(helper.auth_tkt.remote_addr, '0.0.0.0') - self.assertEqual(helper.auth_tkt.secret, 'secret') - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], '') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_int_useridtype(self): - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = '1' - helper.auth_tkt.user_data = 'userid_type:int' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 1) - self.assertEqual(result['userdata'], 'userid_type:int') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:int') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_nonuseridtype_user_data(self): - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = '1' - helper.auth_tkt.user_data = 'bogus:int' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], '1') - self.assertEqual(result['userdata'], 'bogus:int') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'bogus:int') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_unknown_useridtype(self): - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = 'abc' - helper.auth_tkt.user_data = 'userid_type:unknown' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], 'abc') - self.assertEqual(result['userdata'], 'userid_type:unknown') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:unknown') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_b64str_useridtype(self): - from base64 import b64encode - - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = b64encode(b'encoded').strip() - helper.auth_tkt.user_data = 'userid_type:b64str' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], b'encoded') - self.assertEqual(result['userdata'], 'userid_type:b64str') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:b64str') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_good_cookie_b64unicode_useridtype(self): - from base64 import b64encode - - helper = self._makeOne('secret', include_ip=False) - helper.auth_tkt.userid = b64encode(b'\xc3\xa9ncoded').strip() - helper.auth_tkt.user_data = 'userid_type:b64unicode' - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(len(result), 4) - self.assertEqual(result['tokens'], ()) - self.assertEqual(result['userid'], text_(b'\xc3\xa9ncoded', 'utf-8')) - self.assertEqual(result['userdata'], 'userid_type:b64unicode') - self.assertEqual(result['timestamp'], 0) - environ = request.environ - self.assertEqual(environ['REMOTE_USER_TOKENS'], ()) - self.assertEqual(environ['REMOTE_USER_DATA'], 'userid_type:b64unicode') - self.assertEqual(environ['AUTH_TYPE'], 'cookie') - - def test_identify_bad_cookie(self): - helper = self._makeOne('secret', include_ip=True) - helper.auth_tkt.parse_raise = True - request = self._makeRequest('ticket') - result = helper.identify(request) - self.assertEqual(result, None) - - def test_identify_cookie_timeout(self): - helper = self._makeOne('secret', timeout=1) - self.assertEqual(helper.timeout, 1) - - def test_identify_cookie_str_timeout(self): - helper = self._makeOne('secret', timeout='1') - self.assertEqual(helper.timeout, 1) - - def test_identify_cookie_timeout_aged(self): - import time - - helper = self._makeOne('secret', timeout=10) - now = time.time() - helper.auth_tkt.timestamp = now - 1 - helper.now = now + 10 - helper.auth_tkt.tokens = (text_('a'),) - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertFalse(result) - - def test_identify_cookie_reissue(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - helper.auth_tkt.tokens = (text_('a'),) - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](request, response) - self.assertEqual(len(response.headerlist), 3) - self.assertEqual(response.headerlist[0][0], 'Set-Cookie') - - def test_identify_cookie_str_reissue(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time='0') - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - helper.auth_tkt.tokens = (text_('a'),) - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](request, response) - self.assertEqual(len(response.headerlist), 3) - self.assertEqual(response.headerlist[0][0], 'Set-Cookie') - - def test_identify_cookie_reissue_already_reissued_this_request(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - request._authtkt_reissued = True - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 0) - - def test_identify_cookie_reissue_notyet(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=10) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 0) - - def test_identify_cookie_reissue_revoked_by_forget(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - result = helper.forget(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](request, response) - self.assertEqual(len(response.headerlist), 0) - - def test_identify_cookie_reissue_revoked_by_remember(self): - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - result = helper.remember(request, 'bob') - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](request, response) - self.assertEqual(len(response.headerlist), 0) - - def test_identify_cookie_reissue_with_tokens_default(self): - # see https://github.com/Pylons/pyramid/issues#issue/108 - import time - - helper = self._makeOne('secret', timeout=10, reissue_time=0) - auth_tkt = DummyAuthTktModule(tokens=['']) - helper.auth_tkt = auth_tkt - helper.AuthTicket = auth_tkt.AuthTicket - helper.parse_ticket = auth_tkt.parse_ticket - helper.BadTicket = auth_tkt.BadTicket - now = time.time() - helper.auth_tkt.timestamp = now - helper.now = now + 1 - request = self._makeRequest('bogus') - result = helper.identify(request) - self.assertTrue(result) - self.assertEqual(len(request.callbacks), 1) - response = DummyResponse() - request.callbacks[0](None, response) - self.assertEqual(len(response.headerlist), 3) - self.assertEqual(response.headerlist[0][0], 'Set-Cookie') - self.assertTrue("/tokens=/" in response.headerlist[0][1]) - - def test_remember(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue( - result[2][1].endswith('; Domain=.localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_nondefault_samesite(self): - helper = self._makeOne('secret', samesite='Strict') - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Strict')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith( - '; Domain=localhost; Path=/; SameSite=Strict' - ) - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue( - result[2][1].endswith( - '; Domain=.localhost; Path=/; SameSite=Strict' - ) - ) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_None_samesite(self): - helper = self._makeOne('secret', samesite=None) - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/')) # no samesite - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue(result[1][1].endswith('; Domain=localhost; Path=/')) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue(result[2][1].endswith('; Domain=.localhost; Path=/')) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_include_ip(self): - helper = self._makeOne('secret', include_ip=True) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue( - result[2][1].endswith('; Domain=.localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_path(self): - helper = self._makeOne( - 'secret', include_ip=True, path="/cgi-bin/app.cgi/" - ) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue( - result[0][1].endswith('; Path=/cgi-bin/app.cgi/; SameSite=Lax') - ) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith( - '; Domain=localhost; Path=/cgi-bin/app.cgi/; SameSite=Lax' - ) - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue( - result[2][1].endswith( - '; Domain=.localhost; Path=/cgi-bin/app.cgi/; SameSite=Lax' - ) - ) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_http_only(self): - helper = self._makeOne('secret', include_ip=True, http_only=True) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; HttpOnly; SameSite=Lax')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue('; HttpOnly' in result[1][1]) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue('; HttpOnly' in result[2][1]) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_secure(self): - helper = self._makeOne('secret', include_ip=True, secure=True) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue('; secure' in result[0][1]) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue('; secure' in result[1][1]) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue('; secure' in result[2][1]) - self.assertTrue(result[2][1].startswith('auth_tkt=')) - - def test_remember_wild_domain_disabled(self): - helper = self._makeOne('secret', wild_domain=False) - request = self._makeRequest() - result = helper.remember(request, 'other') - self.assertEqual(len(result), 2) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue(result[0][1].endswith('; Path=/; SameSite=Lax')) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue( - result[1][1].endswith('; Domain=localhost; Path=/; SameSite=Lax') - ) - self.assertTrue(result[1][1].startswith('auth_tkt=')) - - def test_remember_parent_domain(self): - helper = self._makeOne('secret', parent_domain=True) - request = self._makeRequest() - request.domain = 'www.example.com' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 1) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue( - result[0][1].endswith( - '; Domain=.example.com; Path=/; SameSite=Lax' - ) - ) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - def test_remember_parent_domain_supercedes_wild_domain(self): - helper = self._makeOne('secret', parent_domain=True, wild_domain=True) - request = self._makeRequest() - request.domain = 'www.example.com' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 1) - self.assertTrue( - result[0][1].endswith( - '; Domain=.example.com; Path=/; SameSite=Lax' - ) - ) - - def test_remember_explicit_domain(self): - helper = self._makeOne('secret', domain='pyramid.bazinga') - request = self._makeRequest() - request.domain = 'www.example.com' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 1) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue( - result[0][1].endswith( - '; Domain=pyramid.bazinga; Path=/; SameSite=Lax' - ) - ) - self.assertTrue(result[0][1].startswith('auth_tkt=')) - - def test_remember_domain_supercedes_parent_and_wild_domain(self): - helper = self._makeOne( - 'secret', - domain='pyramid.bazinga', - parent_domain=True, - wild_domain=True, - ) - request = self._makeRequest() - request.domain = 'www.example.com' - result = helper.remember(request, 'other') - self.assertEqual(len(result), 1) - self.assertTrue( - result[0][1].endswith( - '; Domain=pyramid.bazinga; Path=/; SameSite=Lax' - ) - ) - - def test_remember_binary_userid(self): - import base64 - - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, b'userid') - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - val = self._cookieValue(values[0]) - self.assertEqual( - val['userid'], text_(base64.b64encode(b'userid').strip()) - ) - self.assertEqual(val['user_data'], 'userid_type:b64str') - - def test_remember_int_userid(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 1) - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - val = self._cookieValue(values[0]) - self.assertEqual(val['userid'], '1') - self.assertEqual(val['user_data'], 'userid_type:int') - - def test_remember_unicode_userid(self): - import base64 - - helper = self._makeOne('secret') - request = self._makeRequest() - userid = text_(b'\xc2\xa9', 'utf-8') - result = helper.remember(request, userid) - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - val = self._cookieValue(values[0]) - self.assertEqual( - val['userid'], text_(base64.b64encode(userid.encode('utf-8'))) - ) - self.assertEqual(val['user_data'], 'userid_type:b64unicode') - - def test_remember_insane_userid(self): - helper = self._makeOne('secret') - request = self._makeRequest() - userid = object() - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always', RuntimeWarning) - result = helper.remember(request, userid) - self.assertTrue(str(w[-1].message).startswith('userid is of type')) - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - value = values[0] - self.assertTrue('userid' in value.value) - - def test_remember_max_age(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'userid', max_age=500) - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - - self.assertEqual(values[0]['max-age'], '500') - self.assertTrue(values[0]['expires']) - - def test_remember_str_max_age(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'userid', max_age='500') - values = self._parseHeaders(result) - self.assertEqual(len(result), 3) - - self.assertEqual(values[0]['max-age'], '500') - self.assertTrue(values[0]['expires']) - - def test_remember_str_max_age_invalid(self): - helper = self._makeOne('secret') - request = self._makeRequest() - self.assertRaises( - ValueError, - helper.remember, - request, - 'userid', - max_age='invalid value', - ) - - def test_remember_tokens(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'other', tokens=('foo', 'bar')) - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - self.assertTrue("/tokens=foo|bar/" in result[0][1]) - - self.assertEqual(result[1][0], 'Set-Cookie') - self.assertTrue("/tokens=foo|bar/" in result[1][1]) - - self.assertEqual(result[2][0], 'Set-Cookie') - self.assertTrue("/tokens=foo|bar/" in result[2][1]) - - def test_remember_samesite_nondefault(self): - helper = self._makeOne('secret', samesite='Strict') - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - cookieval = result[0][1] - self.assertTrue( - 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - self.assertEqual(result[1][0], 'Set-Cookie') - cookieval = result[1][1] - self.assertTrue( - 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - self.assertEqual(result[2][0], 'Set-Cookie') - cookieval = result[2][1] - self.assertTrue( - 'SameSite=Strict' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - def test_remember_samesite_default(self): - helper = self._makeOne('secret') - request = self._makeRequest() - result = helper.remember(request, 'userid') - self.assertEqual(len(result), 3) - - self.assertEqual(result[0][0], 'Set-Cookie') - cookieval = result[0][1] - self.assertTrue( - 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - self.assertEqual(result[1][0], 'Set-Cookie') - cookieval = result[1][1] - self.assertTrue( - 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - self.assertEqual(result[2][0], 'Set-Cookie') - cookieval = result[2][1] - self.assertTrue( - 'SameSite=Lax' in [x.strip() for x in cookieval.split(';')], - cookieval, - ) - - def test_remember_unicode_but_ascii_token(self): - helper = self._makeOne('secret') - request = self._makeRequest() - la = text_(b'foo', 'utf-8') - result = helper.remember(request, 'other', tokens=(la,)) - # tokens must be str type on both Python 2 and 3 - self.assertTrue("/tokens=foo/" in result[0][1]) - - def test_remember_nonascii_token(self): - helper = self._makeOne('secret') - request = self._makeRequest() - la = text_(b'La Pe\xc3\xb1a', 'utf-8') - self.assertRaises( - ValueError, helper.remember, request, 'other', tokens=(la,) - ) - - def test_remember_invalid_token_format(self): - helper = self._makeOne('secret') - request = self._makeRequest() - self.assertRaises( - ValueError, helper.remember, request, 'other', tokens=('foo bar',) - ) - self.assertRaises( - ValueError, helper.remember, request, 'other', tokens=('1bar',) - ) - - def test_forget(self): - helper = self._makeOne('secret') - request = self._makeRequest() - headers = helper.forget(request) - self.assertEqual(len(headers), 3) - name, value = headers[0] - self.assertEqual(name, 'Set-Cookie') - self.assertEqual( - value, - 'auth_tkt=; Max-Age=0; Path=/; ' - 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', - ) - name, value = headers[1] - self.assertEqual(name, 'Set-Cookie') - self.assertEqual( - value, - 'auth_tkt=; Domain=localhost; Max-Age=0; Path=/; ' - 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', - ) - name, value = headers[2] - self.assertEqual(name, 'Set-Cookie') - self.assertEqual( - value, - 'auth_tkt=; Domain=.localhost; Max-Age=0; Path=/; ' - 'expires=Wed, 31-Dec-97 23:59:59 GMT; SameSite=Lax', - ) - - -class DummyAuthTktModule(object): - def __init__( - self, - timestamp=0, - userid='userid', - tokens=(), - user_data='', - parse_raise=False, - hashalg="md5", - ): - self.timestamp = timestamp - self.userid = userid - self.tokens = tokens - self.user_data = user_data - self.parse_raise = parse_raise - self.hashalg = hashalg - - def parse_ticket(secret, value, remote_addr, hashalg): - self.secret = secret - self.value = value - self.remote_addr = remote_addr - if self.parse_raise: - raise self.BadTicket() - return self.timestamp, self.userid, self.tokens, self.user_data - - self.parse_ticket = parse_ticket - - class AuthTicket(object): - def __init__(self, secret, userid, remote_addr, **kw): - self.secret = secret - self.userid = userid - self.remote_addr = remote_addr - self.kw = kw - - def cookie_value(self): - result = { - 'secret': self.secret, - 'userid': self.userid, - 'remote_addr': self.remote_addr, - } - result.update(self.kw) - tokens = result.pop('tokens', None) - if tokens is not None: - tokens = '|'.join(tokens) - result['tokens'] = tokens - items = sorted(result.items()) - new_items = [] - for k, v in items: - if isinstance(v, bytes): - v = text_(v) - new_items.append((k, v)) - result = '/'.join(['%s=%s' % (k, v) for k, v in new_items]) - return result - - self.AuthTicket = AuthTicket - - class BadTicket(Exception): - pass - - -class DummyCookies(object): - def __init__(self, cookie): - self.cookie = cookie - - def get(self, name): - return self.cookie - - -class DummyRequest: - domain = 'localhost' - - def __init__(self, environ=None, session=None, registry=None, cookie=None): - self.environ = environ or {} - self.session = session or {} - self.registry = registry - self.callbacks = [] - self.cookies = DummyCookies(cookie) - - def add_response_callback(self, callback): - self.callbacks.append(callback) - - -class DummyResponse: - def __init__(self): - self.headerlist = [] -- cgit v1.2.3 From d6e543bc01d2f1aa3bb29f005171911f6f09da02 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Mon, 15 Apr 2019 19:14:42 -0700 Subject: Move SessionAuthenticationHelper to pyramid.authentication. --- src/pyramid/authentication.py | 31 +++++++++++++++++++++++++++ src/pyramid/security.py | 31 --------------------------- tests/test_authentication.py | 50 +++++++++++++++++++++++++++++++++++++++++++ tests/test_security.py | 50 ------------------------------------------- 4 files changed, 81 insertions(+), 81 deletions(-) diff --git a/src/pyramid/authentication.py b/src/pyramid/authentication.py index 21cfc0c0e..4f8077309 100644 --- a/src/pyramid/authentication.py +++ b/src/pyramid/authentication.py @@ -1118,6 +1118,37 @@ class SessionAuthenticationPolicy(CallbackAuthenticationPolicy): return request.session.get(self.userid_key) +class SessionAuthenticationHelper: + """ A helper for use with a :term:`security policy` which stores user data + in the configured :term:`session`. + + Constructor Arguments + + ``prefix`` + + A prefix used when storing the authentication parameters in the + session. Defaults to 'auth.'. Optional. + + """ + + def __init__(self, prefix='auth.'): + self.userid_key = prefix + 'userid' + + def remember(self, request, userid, **kw): + """ Store a userid in the session.""" + request.session[self.userid_key] = userid + return [] + + def forget(self, request): + """ Remove the stored userid from the session.""" + if self.userid_key in request.session: + del request.session[self.userid_key] + return [] + + def identify(self, request): + return request.session.get(self.userid_key) + + @implementer(IAuthenticationPolicy) class BasicAuthAuthenticationPolicy(CallbackAuthenticationPolicy): """ A :app:`Pyramid` authentication policy which uses HTTP standard basic diff --git a/src/pyramid/security.py b/src/pyramid/security.py index dda61ef27..5d157d219 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -540,34 +540,3 @@ class ACLHelper: allowed.update(allowed_here) return allowed - - -class SessionAuthenticationHelper: - """ A helper for use with a :term:`security policy` which stores user data - in the configured :term:`session`. - - Constructor Arguments - - ``prefix`` - - A prefix used when storing the authentication parameters in the - session. Defaults to 'auth.'. Optional. - - """ - - def __init__(self, prefix='auth.'): - self.userid_key = prefix + 'userid' - - def remember(self, request, userid, **kw): - """ Store a userid in the session.""" - request.session[self.userid_key] = userid - return [] - - def forget(self, request): - """ Remove the stored userid from the session.""" - if self.userid_key in request.session: - del request.session[self.userid_key] - return [] - - def identify(self, request): - return request.session.get(self.userid_key) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 8671eba05..710e87423 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1693,6 +1693,56 @@ class TestSessionAuthenticationPolicy(unittest.TestCase): self.assertEqual(result, []) +class TestSessionAuthenticationHelper(unittest.TestCase): + def _makeRequest(self, session=None): + from types import SimpleNamespace + + if session is None: + session = dict() + return SimpleNamespace(session=session) + + def _makeOne(self, prefix=''): + from pyramid.authentication import SessionAuthenticationHelper + + return SessionAuthenticationHelper(prefix=prefix) + + def test_identify(self): + request = self._makeRequest({'userid': 'fred'}) + helper = self._makeOne() + self.assertEqual(helper.identify(request), 'fred') + + def test_identify_with_prefix(self): + request = self._makeRequest({'foo.userid': 'fred'}) + helper = self._makeOne(prefix='foo.') + self.assertEqual(helper.identify(request), 'fred') + + def test_identify_none(self): + request = self._makeRequest() + helper = self._makeOne() + self.assertEqual(helper.identify(request), None) + + def test_remember(self): + request = self._makeRequest() + helper = self._makeOne() + result = helper.remember(request, 'fred') + self.assertEqual(request.session.get('userid'), 'fred') + self.assertEqual(result, []) + + def test_forget(self): + request = self._makeRequest({'userid': 'fred'}) + helper = self._makeOne() + result = helper.forget(request) + self.assertEqual(request.session.get('userid'), None) + self.assertEqual(result, []) + + def test_forget_no_identity(self): + request = self._makeRequest() + helper = self._makeOne() + result = helper.forget(request) + self.assertEqual(request.session.get('userid'), None) + self.assertEqual(result, []) + + class TestBasicAuthAuthenticationPolicy(unittest.TestCase): def _getTargetClass(self): from pyramid.authentication import BasicAuthAuthenticationPolicy as cls diff --git a/tests/test_security.py b/tests/test_security.py index f14159156..ecd6a088b 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -887,53 +887,3 @@ GUEST_PERMS = (VIEW, COMMENT) MEMBER_PERMS = GUEST_PERMS + (EDIT, CREATE, DELETE) MODERATOR_PERMS = MEMBER_PERMS + (MODERATE,) ADMINISTRATOR_PERMS = MODERATOR_PERMS + (ADMINISTER,) - - -class TestSessionAuthenticationHelper(unittest.TestCase): - def _makeRequest(self, session=None): - from types import SimpleNamespace - - if session is None: - session = dict() - return SimpleNamespace(session=session) - - def _makeOne(self, prefix=''): - from pyramid.security import SessionAuthenticationHelper - - return SessionAuthenticationHelper(prefix=prefix) - - def test_identify(self): - request = self._makeRequest({'userid': 'fred'}) - helper = self._makeOne() - self.assertEqual(helper.identify(request), 'fred') - - def test_identify_with_prefix(self): - request = self._makeRequest({'foo.userid': 'fred'}) - helper = self._makeOne(prefix='foo.') - self.assertEqual(helper.identify(request), 'fred') - - def test_identify_none(self): - request = self._makeRequest() - helper = self._makeOne() - self.assertEqual(helper.identify(request), None) - - def test_remember(self): - request = self._makeRequest() - helper = self._makeOne() - result = helper.remember(request, 'fred') - self.assertEqual(request.session.get('userid'), 'fred') - self.assertEqual(result, []) - - def test_forget(self): - request = self._makeRequest({'userid': 'fred'}) - helper = self._makeOne() - result = helper.forget(request) - self.assertEqual(request.session.get('userid'), None) - self.assertEqual(result, []) - - def test_forget_no_identity(self): - request = self._makeRequest() - helper = self._makeOne() - result = helper.forget(request) - self.assertEqual(request.session.get('userid'), None) - self.assertEqual(result, []) -- cgit v1.2.3 From 600ffe25e1d332852f31756a38f6052d876b0c90 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Mon, 15 Apr 2019 19:15:36 -0700 Subject: Use SessionAuthenticationHelper in SessionAuthenticationPolicy. --- src/pyramid/authentication.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/pyramid/authentication.py b/src/pyramid/authentication.py index 4f8077309..c1e010516 100644 --- a/src/pyramid/authentication.py +++ b/src/pyramid/authentication.py @@ -1099,23 +1099,19 @@ class SessionAuthenticationPolicy(CallbackAuthenticationPolicy): def __init__(self, prefix='auth.', callback=None, debug=False): self.callback = callback - self.prefix = prefix or '' - self.userid_key = prefix + 'userid' self.debug = debug + self.helper = SessionAuthenticationHelper(prefix) def remember(self, request, userid, **kw): """ Store a userid in the session.""" - request.session[self.userid_key] = userid - return [] + return self.helper.remember(request, userid, **kw) def forget(self, request): """ Remove the stored userid from the session.""" - if self.userid_key in request.session: - del request.session[self.userid_key] - return [] + return self.helper.forget(request) def unauthenticated_userid(self, request): - return request.session.get(self.userid_key) + return self.helper.identify(request) class SessionAuthenticationHelper: -- cgit v1.2.3 From 5497c0f7166308031b3cc3ce2510d22eb214b2ef Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Mon, 15 Apr 2019 19:32:11 -0700 Subject: Move ACLHelper to pyramid.authorizations. --- src/pyramid/authorization.py | 131 ++++++++++++++++++++- src/pyramid/security.py | 129 -------------------- tests/test_authorization.py | 261 ++++++++++++++++++++++++++++++++++++++++ tests/test_security.py | 275 ------------------------------------------- 4 files changed, 391 insertions(+), 405 deletions(-) diff --git a/src/pyramid/authorization.py b/src/pyramid/authorization.py index 19b96e3d1..cb629e257 100644 --- a/src/pyramid/authorization.py +++ b/src/pyramid/authorization.py @@ -2,7 +2,11 @@ from zope.interface import implementer from pyramid.interfaces import IAuthorizationPolicy -from pyramid.security import ACLHelper +from pyramid.location import lineage + +from pyramid.security import ACLAllowed, ACLDenied, Allow, Deny, Everyone + +from pyramid.util import is_nonstr_iter @implementer(IAuthorizationPolicy) @@ -75,3 +79,128 @@ class ACLAuthorizationPolicy(object): return self.helper.principals_allowed_by_permission( context, permission ) + + +class ACLHelper: + """ A helper for use with constructing a :term:`security policy` which + consults an :term:`ACL` object attached to a :term:`context` to determine + authorization information about a :term:`principal` or multiple principals. + If the context is part of a :term:`lineage`, the context's parents are + consulted for ACL information too. + + """ + + def permits(self, context, principals, permission): + """ Return an instance of :class:`pyramid.security.ACLAllowed` if the + ACL allows access a user with the given principals, return an instance + of :class:`pyramid.security.ACLDenied` if not. + + When checking if principals are allowed, the security policy consults + the ``context`` for an ACL first. If no ACL exists on the context, or + one does exist but the ACL does not explicitly allow or deny access for + any of the effective principals, consult the context's parent ACL, and + so on, until the lineage is exhausted or we determine that the policy + permits or denies. + + During this processing, if any :data:`pyramid.security.Deny` + ACE is found matching any principal in ``principals``, stop + processing by returning an + :class:`pyramid.security.ACLDenied` instance (equals + ``False``) immediately. If any + :data:`pyramid.security.Allow` ACE is found matching any + principal, stop processing by returning an + :class:`pyramid.security.ACLAllowed` instance (equals + ``True``) immediately. If we exhaust the context's + :term:`lineage`, and no ACE has explicitly permitted or denied + access, return an instance of + :class:`pyramid.security.ACLDenied` (equals ``False``). + + """ + acl = '' + + for location in lineage(context): + try: + acl = location.__acl__ + except AttributeError: + continue + + if acl and callable(acl): + acl = acl() + + for ace in acl: + ace_action, ace_principal, ace_permissions = ace + if ace_principal in principals: + if not is_nonstr_iter(ace_permissions): + ace_permissions = [ace_permissions] + if permission in ace_permissions: + if ace_action == Allow: + return ACLAllowed( + ace, acl, permission, principals, location + ) + else: + return ACLDenied( + ace, acl, permission, principals, location + ) + + # default deny (if no ACL in lineage at all, or if none of the + # principals were mentioned in any ACE we found) + return ACLDenied( + '', acl, permission, principals, context + ) + + def principals_allowed_by_permission(self, context, permission): + """ Return the set of principals explicitly granted the permission + named ``permission`` according to the ACL directly attached to the + ``context`` as well as inherited ACLs based on the :term:`lineage`. + + When computing principals allowed by a permission, we compute the set + of principals that are explicitly granted the ``permission`` in the + provided ``context``. We do this by walking 'up' the object graph + *from the root* to the context. During this walking process, if we + find an explicit :data:`pyramid.security.Allow` ACE for a principal + that matches the ``permission``, the principal is included in the allow + list. However, if later in the walking process that principal is + mentioned in any :data:`pyramid.security.Deny` ACE for the permission, + the principal is removed from the allow list. If a + :data:`pyramid.security.Deny` to the principal + :data:`pyramid.security.Everyone` is encountered during the walking + process that matches the ``permission``, the allow list is cleared for + all principals encountered in previous ACLs. The walking process ends + after we've processed the any ACL directly attached to ``context``; a + set of principals is returned. + + """ + allowed = set() + + for location in reversed(list(lineage(context))): + # NB: we're walking *up* the object graph from the root + try: + acl = location.__acl__ + except AttributeError: + continue + + allowed_here = set() + denied_here = set() + + if acl and callable(acl): + acl = acl() + + for ace_action, ace_principal, ace_permissions in acl: + if not is_nonstr_iter(ace_permissions): + ace_permissions = [ace_permissions] + if (ace_action == Allow) and (permission in ace_permissions): + if ace_principal not in denied_here: + allowed_here.add(ace_principal) + if (ace_action == Deny) and (permission in ace_permissions): + denied_here.add(ace_principal) + if ace_principal == Everyone: + # clear the entire allowed set, as we've hit a + # deny of Everyone ala (Deny, Everyone, ALL) + allowed = set() + break + elif ace_principal in allowed: + allowed.remove(ace_principal) + + allowed.update(allowed_here) + + return allowed diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 5d157d219..5d8e916d7 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -9,10 +9,6 @@ from pyramid.interfaces import ( IViewClassifier, ) -from pyramid.location import lineage - -from pyramid.util import is_nonstr_iter - from pyramid.threadlocal import get_current_registry Everyone = 'system.Everyone' @@ -415,128 +411,3 @@ class LegacySecurityPolicy: authz = self._get_authz_policy(request) principals = authn.effective_principals(request) return authz.permits(context, principals, permission) - - -class ACLHelper: - """ A helper for use with constructing a :term:`security policy` which - consults an :term:`ACL` object attached to a :term:`context` to determine - authorization information about a :term:`principal` or multiple principals. - If the context is part of a :term:`lineage`, the context's parents are - consulted for ACL information too. - - """ - - def permits(self, context, principals, permission): - """ Return an instance of :class:`pyramid.security.ACLAllowed` if the - ACL allows access a user with the given principals, return an instance - of :class:`pyramid.security.ACLDenied` if not. - - When checking if principals are allowed, the security policy consults - the ``context`` for an ACL first. If no ACL exists on the context, or - one does exist but the ACL does not explicitly allow or deny access for - any of the effective principals, consult the context's parent ACL, and - so on, until the lineage is exhausted or we determine that the policy - permits or denies. - - During this processing, if any :data:`pyramid.security.Deny` - ACE is found matching any principal in ``principals``, stop - processing by returning an - :class:`pyramid.security.ACLDenied` instance (equals - ``False``) immediately. If any - :data:`pyramid.security.Allow` ACE is found matching any - principal, stop processing by returning an - :class:`pyramid.security.ACLAllowed` instance (equals - ``True``) immediately. If we exhaust the context's - :term:`lineage`, and no ACE has explicitly permitted or denied - access, return an instance of - :class:`pyramid.security.ACLDenied` (equals ``False``). - - """ - acl = '' - - for location in lineage(context): - try: - acl = location.__acl__ - except AttributeError: - continue - - if acl and callable(acl): - acl = acl() - - for ace in acl: - ace_action, ace_principal, ace_permissions = ace - if ace_principal in principals: - if not is_nonstr_iter(ace_permissions): - ace_permissions = [ace_permissions] - if permission in ace_permissions: - if ace_action == Allow: - return ACLAllowed( - ace, acl, permission, principals, location - ) - else: - return ACLDenied( - ace, acl, permission, principals, location - ) - - # default deny (if no ACL in lineage at all, or if none of the - # principals were mentioned in any ACE we found) - return ACLDenied( - '', acl, permission, principals, context - ) - - def principals_allowed_by_permission(self, context, permission): - """ Return the set of principals explicitly granted the permission - named ``permission`` according to the ACL directly attached to the - ``context`` as well as inherited ACLs based on the :term:`lineage`. - - When computing principals allowed by a permission, we compute the set - of principals that are explicitly granted the ``permission`` in the - provided ``context``. We do this by walking 'up' the object graph - *from the root* to the context. During this walking process, if we - find an explicit :data:`pyramid.security.Allow` ACE for a principal - that matches the ``permission``, the principal is included in the allow - list. However, if later in the walking process that principal is - mentioned in any :data:`pyramid.security.Deny` ACE for the permission, - the principal is removed from the allow list. If a - :data:`pyramid.security.Deny` to the principal - :data:`pyramid.security.Everyone` is encountered during the walking - process that matches the ``permission``, the allow list is cleared for - all principals encountered in previous ACLs. The walking process ends - after we've processed the any ACL directly attached to ``context``; a - set of principals is returned. - - """ - allowed = set() - - for location in reversed(list(lineage(context))): - # NB: we're walking *up* the object graph from the root - try: - acl = location.__acl__ - except AttributeError: - continue - - allowed_here = set() - denied_here = set() - - if acl and callable(acl): - acl = acl() - - for ace_action, ace_principal, ace_permissions in acl: - if not is_nonstr_iter(ace_permissions): - ace_permissions = [ace_permissions] - if (ace_action == Allow) and (permission in ace_permissions): - if ace_principal not in denied_here: - allowed_here.add(ace_principal) - if (ace_action == Deny) and (permission in ace_permissions): - denied_here.add(ace_principal) - if ace_principal == Everyone: - # clear the entire allowed set, as we've hit a - # deny of Everyone ala (Deny, Everyone, ALL) - allowed = set() - break - elif ace_principal in allowed: - allowed.remove(ace_principal) - - allowed.update(allowed_here) - - return allowed diff --git a/tests/test_authorization.py b/tests/test_authorization.py index efb84b203..399b3da60 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -272,6 +272,267 @@ class TestACLAuthorizationPolicy(unittest.TestCase): self.assertTrue(result) +class TestACLHelper(unittest.TestCase): + def test_no_acl(self): + from pyramid.authorization import ACLHelper + + context = DummyContext() + helper = ACLHelper() + result = helper.permits(context, ['foo'], 'permission') + self.assertEqual(result, False) + self.assertEqual(result.ace, '') + self.assertEqual( + result.acl, '' + ) + self.assertEqual(result.permission, 'permission') + self.assertEqual(result.principals, ['foo']) + self.assertEqual(result.context, context) + + def test_acl(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Deny + from pyramid.security import Allow + from pyramid.security import Everyone + from pyramid.security import Authenticated + from pyramid.security import ALL_PERMISSIONS + from pyramid.security import DENY_ALL + + helper = ACLHelper() + root = DummyContext() + community = DummyContext(__name__='community', __parent__=root) + blog = DummyContext(__name__='blog', __parent__=community) + root.__acl__ = [(Allow, Authenticated, VIEW)] + community.__acl__ = [ + (Allow, 'fred', ALL_PERMISSIONS), + (Allow, 'wilma', VIEW), + DENY_ALL, + ] + blog.__acl__ = [ + (Allow, 'barney', MEMBER_PERMS), + (Allow, 'wilma', VIEW), + ] + + result = helper.permits( + blog, [Everyone, Authenticated, 'wilma'], 'view' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, blog) + self.assertEqual(result.ace, (Allow, 'wilma', VIEW)) + self.assertEqual(result.acl, blog.__acl__) + + result = helper.permits( + blog, [Everyone, Authenticated, 'wilma'], 'delete' + ) + self.assertEqual(result, False) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) + self.assertEqual(result.acl, community.__acl__) + + result = helper.permits( + blog, [Everyone, Authenticated, 'fred'], 'view' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Allow, 'fred', ALL_PERMISSIONS)) + result = helper.permits( + blog, [Everyone, Authenticated, 'fred'], 'doesntevenexistyet' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Allow, 'fred', ALL_PERMISSIONS)) + self.assertEqual(result.acl, community.__acl__) + + result = helper.permits( + blog, [Everyone, Authenticated, 'barney'], 'view' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, blog) + self.assertEqual(result.ace, (Allow, 'barney', MEMBER_PERMS)) + result = helper.permits( + blog, [Everyone, Authenticated, 'barney'], 'administer' + ) + self.assertEqual(result, False) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) + self.assertEqual(result.acl, community.__acl__) + + result = helper.permits( + root, [Everyone, Authenticated, 'someguy'], 'view' + ) + self.assertEqual(result, True) + self.assertEqual(result.context, root) + self.assertEqual(result.ace, (Allow, Authenticated, VIEW)) + result = helper.permits( + blog, [Everyone, Authenticated, 'someguy'], 'view' + ) + self.assertEqual(result, False) + self.assertEqual(result.context, community) + self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) + self.assertEqual(result.acl, community.__acl__) + + result = helper.permits(root, [Everyone], 'view') + self.assertEqual(result, False) + self.assertEqual(result.context, root) + self.assertEqual(result.ace, '') + self.assertEqual(result.acl, root.__acl__) + + context = DummyContext() + result = helper.permits(context, [Everyone], 'view') + self.assertEqual(result, False) + self.assertEqual(result.ace, '') + self.assertEqual( + result.acl, '' + ) + + def test_string_permissions_in_acl(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Allow + + helper = ACLHelper() + root = DummyContext() + root.__acl__ = [(Allow, 'wilma', 'view_stuff')] + + result = helper.permits(root, ['wilma'], 'view') + # would be True if matching against 'view_stuff' instead of against + # ['view_stuff'] + self.assertEqual(result, False) + + def test_callable_acl(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Allow + + helper = ACLHelper() + context = DummyContext() + fn = lambda self: [(Allow, 'bob', 'read')] + context.__acl__ = fn.__get__(context, context.__class__) + result = helper.permits(context, ['bob'], 'read') + self.assertTrue(result) + + def test_principals_allowed_by_permission_direct(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Allow + from pyramid.security import DENY_ALL + + helper = ACLHelper() + context = DummyContext() + acl = [ + (Allow, 'chrism', ('read', 'write')), + DENY_ALL, + (Allow, 'other', 'read'), + ] + context.__acl__ = acl + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, ['chrism']) + + def test_principals_allowed_by_permission_callable_acl(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Allow + from pyramid.security import DENY_ALL + + helper = ACLHelper() + context = DummyContext() + acl = lambda: [ + (Allow, 'chrism', ('read', 'write')), + DENY_ALL, + (Allow, 'other', 'read'), + ] + context.__acl__ = acl + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, ['chrism']) + + def test_principals_allowed_by_permission_string_permission(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Allow + + helper = ACLHelper() + context = DummyContext() + acl = [(Allow, 'chrism', 'read_it')] + context.__acl__ = acl + result = helper.principals_allowed_by_permission(context, 'read') + # would be ['chrism'] if 'read' were compared against 'read_it' instead + # of against ['read_it'] + self.assertEqual(list(result), []) + + def test_principals_allowed_by_permission(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Allow + from pyramid.security import Deny + from pyramid.security import DENY_ALL + from pyramid.security import ALL_PERMISSIONS + + helper = ACLHelper() + root = DummyContext(__name__='', __parent__=None) + community = DummyContext(__name__='community', __parent__=root) + blog = DummyContext(__name__='blog', __parent__=community) + root.__acl__ = [ + (Allow, 'chrism', ('read', 'write')), + (Allow, 'other', ('read',)), + (Allow, 'jim', ALL_PERMISSIONS), + ] + community.__acl__ = [ + (Deny, 'flooz', 'read'), + (Allow, 'flooz', 'read'), + (Allow, 'mork', 'read'), + (Deny, 'jim', 'read'), + (Allow, 'someguy', 'manage'), + ] + blog.__acl__ = [(Allow, 'fred', 'read'), DENY_ALL] + + result = sorted(helper.principals_allowed_by_permission(blog, 'read')) + self.assertEqual(result, ['fred']) + result = sorted( + helper.principals_allowed_by_permission(community, 'read') + ) + self.assertEqual(result, ['chrism', 'mork', 'other']) + result = sorted( + helper.principals_allowed_by_permission(community, 'read') + ) + result = sorted(helper.principals_allowed_by_permission(root, 'read')) + self.assertEqual(result, ['chrism', 'jim', 'other']) + + def test_principals_allowed_by_permission_no_acls(self): + from pyramid.authorization import ACLHelper + + helper = ACLHelper() + context = DummyContext() + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, []) + + def test_principals_allowed_by_permission_deny_not_permission_in_acl(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Deny + from pyramid.security import Everyone + + helper = ACLHelper() + context = DummyContext() + acl = [(Deny, Everyone, 'write')] + context.__acl__ = acl + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, []) + + def test_principals_allowed_by_permission_deny_permission_in_acl(self): + from pyramid.authorization import ACLHelper + from pyramid.security import Deny + from pyramid.security import Everyone + + helper = ACLHelper() + context = DummyContext() + acl = [(Deny, Everyone, 'read')] + context.__acl__ = acl + result = sorted( + helper.principals_allowed_by_permission(context, 'read') + ) + self.assertEqual(result, []) + + class DummyContext: def __init__(self, *arg, **kw): self.__dict__.update(kw) diff --git a/tests/test_security.py b/tests/test_security.py index ecd6a088b..5a0307c66 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -612,278 +612,3 @@ def _makeRequest(): request.registry = Registry() request.context = object() return request - - -class TestACLHelper(unittest.TestCase): - def test_no_acl(self): - from pyramid.security import ACLHelper - - context = DummyContext() - helper = ACLHelper() - result = helper.permits(context, ['foo'], 'permission') - self.assertEqual(result, False) - self.assertEqual(result.ace, '') - self.assertEqual( - result.acl, '' - ) - self.assertEqual(result.permission, 'permission') - self.assertEqual(result.principals, ['foo']) - self.assertEqual(result.context, context) - - def test_acl(self): - from pyramid.security import ACLHelper - from pyramid.security import Deny - from pyramid.security import Allow - from pyramid.security import Everyone - from pyramid.security import Authenticated - from pyramid.security import ALL_PERMISSIONS - from pyramid.security import DENY_ALL - - helper = ACLHelper() - root = DummyContext() - community = DummyContext(__name__='community', __parent__=root) - blog = DummyContext(__name__='blog', __parent__=community) - root.__acl__ = [(Allow, Authenticated, VIEW)] - community.__acl__ = [ - (Allow, 'fred', ALL_PERMISSIONS), - (Allow, 'wilma', VIEW), - DENY_ALL, - ] - blog.__acl__ = [ - (Allow, 'barney', MEMBER_PERMS), - (Allow, 'wilma', VIEW), - ] - - result = helper.permits( - blog, [Everyone, Authenticated, 'wilma'], 'view' - ) - self.assertEqual(result, True) - self.assertEqual(result.context, blog) - self.assertEqual(result.ace, (Allow, 'wilma', VIEW)) - self.assertEqual(result.acl, blog.__acl__) - - result = helper.permits( - blog, [Everyone, Authenticated, 'wilma'], 'delete' - ) - self.assertEqual(result, False) - self.assertEqual(result.context, community) - self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) - self.assertEqual(result.acl, community.__acl__) - - result = helper.permits( - blog, [Everyone, Authenticated, 'fred'], 'view' - ) - self.assertEqual(result, True) - self.assertEqual(result.context, community) - self.assertEqual(result.ace, (Allow, 'fred', ALL_PERMISSIONS)) - result = helper.permits( - blog, [Everyone, Authenticated, 'fred'], 'doesntevenexistyet' - ) - self.assertEqual(result, True) - self.assertEqual(result.context, community) - self.assertEqual(result.ace, (Allow, 'fred', ALL_PERMISSIONS)) - self.assertEqual(result.acl, community.__acl__) - - result = helper.permits( - blog, [Everyone, Authenticated, 'barney'], 'view' - ) - self.assertEqual(result, True) - self.assertEqual(result.context, blog) - self.assertEqual(result.ace, (Allow, 'barney', MEMBER_PERMS)) - result = helper.permits( - blog, [Everyone, Authenticated, 'barney'], 'administer' - ) - self.assertEqual(result, False) - self.assertEqual(result.context, community) - self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) - self.assertEqual(result.acl, community.__acl__) - - result = helper.permits( - root, [Everyone, Authenticated, 'someguy'], 'view' - ) - self.assertEqual(result, True) - self.assertEqual(result.context, root) - self.assertEqual(result.ace, (Allow, Authenticated, VIEW)) - result = helper.permits( - blog, [Everyone, Authenticated, 'someguy'], 'view' - ) - self.assertEqual(result, False) - self.assertEqual(result.context, community) - self.assertEqual(result.ace, (Deny, Everyone, ALL_PERMISSIONS)) - self.assertEqual(result.acl, community.__acl__) - - result = helper.permits(root, [Everyone], 'view') - self.assertEqual(result, False) - self.assertEqual(result.context, root) - self.assertEqual(result.ace, '') - self.assertEqual(result.acl, root.__acl__) - - context = DummyContext() - result = helper.permits(context, [Everyone], 'view') - self.assertEqual(result, False) - self.assertEqual(result.ace, '') - self.assertEqual( - result.acl, '' - ) - - def test_string_permissions_in_acl(self): - from pyramid.security import ACLHelper - from pyramid.security import Allow - - helper = ACLHelper() - root = DummyContext() - root.__acl__ = [(Allow, 'wilma', 'view_stuff')] - - result = helper.permits(root, ['wilma'], 'view') - # would be True if matching against 'view_stuff' instead of against - # ['view_stuff'] - self.assertEqual(result, False) - - def test_callable_acl(self): - from pyramid.security import ACLHelper - from pyramid.security import Allow - - helper = ACLHelper() - context = DummyContext() - fn = lambda self: [(Allow, 'bob', 'read')] - context.__acl__ = fn.__get__(context, context.__class__) - result = helper.permits(context, ['bob'], 'read') - self.assertTrue(result) - - def test_principals_allowed_by_permission_direct(self): - from pyramid.security import ACLHelper - from pyramid.security import Allow - from pyramid.security import DENY_ALL - - helper = ACLHelper() - context = DummyContext() - acl = [ - (Allow, 'chrism', ('read', 'write')), - DENY_ALL, - (Allow, 'other', 'read'), - ] - context.__acl__ = acl - result = sorted( - helper.principals_allowed_by_permission(context, 'read') - ) - self.assertEqual(result, ['chrism']) - - def test_principals_allowed_by_permission_callable_acl(self): - from pyramid.security import ACLHelper - from pyramid.security import Allow - from pyramid.security import DENY_ALL - - helper = ACLHelper() - context = DummyContext() - acl = lambda: [ - (Allow, 'chrism', ('read', 'write')), - DENY_ALL, - (Allow, 'other', 'read'), - ] - context.__acl__ = acl - result = sorted( - helper.principals_allowed_by_permission(context, 'read') - ) - self.assertEqual(result, ['chrism']) - - def test_principals_allowed_by_permission_string_permission(self): - from pyramid.security import ACLHelper - from pyramid.security import Allow - - helper = ACLHelper() - context = DummyContext() - acl = [(Allow, 'chrism', 'read_it')] - context.__acl__ = acl - result = helper.principals_allowed_by_permission(context, 'read') - # would be ['chrism'] if 'read' were compared against 'read_it' instead - # of against ['read_it'] - self.assertEqual(list(result), []) - - def test_principals_allowed_by_permission(self): - from pyramid.security import ACLHelper - from pyramid.security import Allow - from pyramid.security import Deny - from pyramid.security import DENY_ALL - from pyramid.security import ALL_PERMISSIONS - - helper = ACLHelper() - root = DummyContext(__name__='', __parent__=None) - community = DummyContext(__name__='community', __parent__=root) - blog = DummyContext(__name__='blog', __parent__=community) - root.__acl__ = [ - (Allow, 'chrism', ('read', 'write')), - (Allow, 'other', ('read',)), - (Allow, 'jim', ALL_PERMISSIONS), - ] - community.__acl__ = [ - (Deny, 'flooz', 'read'), - (Allow, 'flooz', 'read'), - (Allow, 'mork', 'read'), - (Deny, 'jim', 'read'), - (Allow, 'someguy', 'manage'), - ] - blog.__acl__ = [(Allow, 'fred', 'read'), DENY_ALL] - - result = sorted(helper.principals_allowed_by_permission(blog, 'read')) - self.assertEqual(result, ['fred']) - result = sorted( - helper.principals_allowed_by_permission(community, 'read') - ) - self.assertEqual(result, ['chrism', 'mork', 'other']) - result = sorted( - helper.principals_allowed_by_permission(community, 'read') - ) - result = sorted(helper.principals_allowed_by_permission(root, 'read')) - self.assertEqual(result, ['chrism', 'jim', 'other']) - - def test_principals_allowed_by_permission_no_acls(self): - from pyramid.security import ACLHelper - - helper = ACLHelper() - context = DummyContext() - result = sorted( - helper.principals_allowed_by_permission(context, 'read') - ) - self.assertEqual(result, []) - - def test_principals_allowed_by_permission_deny_not_permission_in_acl(self): - from pyramid.security import ACLHelper - from pyramid.security import Deny - from pyramid.security import Everyone - - helper = ACLHelper() - context = DummyContext() - acl = [(Deny, Everyone, 'write')] - context.__acl__ = acl - result = sorted( - helper.principals_allowed_by_permission(context, 'read') - ) - self.assertEqual(result, []) - - def test_principals_allowed_by_permission_deny_permission_in_acl(self): - from pyramid.security import ACLHelper - from pyramid.security import Deny - from pyramid.security import Everyone - - helper = ACLHelper() - context = DummyContext() - acl = [(Deny, Everyone, 'read')] - context.__acl__ = acl - result = sorted( - helper.principals_allowed_by_permission(context, 'read') - ) - self.assertEqual(result, []) - - -VIEW = 'view' -EDIT = 'edit' -CREATE = 'create' -DELETE = 'delete' -MODERATE = 'moderate' -ADMINISTER = 'administer' -COMMENT = 'comment' - -GUEST_PERMS = (VIEW, COMMENT) -MEMBER_PERMS = GUEST_PERMS + (EDIT, CREATE, DELETE) -MODERATOR_PERMS = MEMBER_PERMS + (MODERATE,) -ADMINISTRATOR_PERMS = MODERATOR_PERMS + (ADMINISTER,) -- cgit v1.2.3 From ad611d2696701b611d2ef9dfe93567ecf6cb338d Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 27 Apr 2019 14:51:57 -0700 Subject: Add simple integration tests for security. --- tests/pkgs/legacysecurityapp/__init__.py | 37 ++++++++++++++++++++++++++++ tests/pkgs/securityapp/__init__.py | 41 +++++++++++++++++++++++++++++++ tests/test_integration.py | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 tests/pkgs/legacysecurityapp/__init__.py create mode 100644 tests/pkgs/securityapp/__init__.py diff --git a/tests/pkgs/legacysecurityapp/__init__.py b/tests/pkgs/legacysecurityapp/__init__.py new file mode 100644 index 000000000..12fb6104e --- /dev/null +++ b/tests/pkgs/legacysecurityapp/__init__.py @@ -0,0 +1,37 @@ +from pyramid.response import Response +from pyramid.authentication import RemoteUserAuthenticationPolicy +from pyramid.security import Allowed, Denied + + +class AuthorizationPolicy: + def permits(self, context, principals, permission): + if 'bob' in principals and permission == 'foo': + return Allowed('') + else: + return Denied('') + + def principals_allowed_by_permission(self, context, permission): + raise NotImplementedError() # pragma: no cover + + +def public(context, request): + return Response('Hello') + + +def private(context, request): + return Response('Secret') + + +def inaccessible(context, request): + raise AssertionError() # pragma: no cover + + +def includeme(config): + config.set_authentication_policy(RemoteUserAuthenticationPolicy()) + config.set_authorization_policy(AuthorizationPolicy()) + config.add_route('public', '/public') + config.add_view(public, route_name='public') + config.add_route('private', '/private') + config.add_view(private, route_name='private', permission='foo') + config.add_route('inaccessible', '/inaccessible') + config.add_view(inaccessible, route_name='inaccessible', permission='bar') diff --git a/tests/pkgs/securityapp/__init__.py b/tests/pkgs/securityapp/__init__.py new file mode 100644 index 000000000..6ddba585b --- /dev/null +++ b/tests/pkgs/securityapp/__init__.py @@ -0,0 +1,41 @@ +from pyramid.response import Response +from pyramid.security import Allowed, Denied + + +class SecurityPolicy: + def identify(self, request): + return request.environ.get('REMOTE_USER') + + def permits(self, request, context, identity, permission): + if identity and permission == 'foo': + return Allowed('') + else: + return Denied('') + + def remember(self, request, userid, **kw): + raise NotImplementedError() # pragma: no cover + + def forget(self, request): + raise NotImplementedError() # pragma: no cover + + +def public(context, request): + return Response('Hello') + + +def private(context, request): + return Response('Secret') + + +def inaccessible(context, request): + raise AssertionError() # pragma: no cover + + +def includeme(config): + config.set_security_policy(SecurityPolicy()) + config.add_route('public', '/public') + config.add_view(public, route_name='public') + config.add_route('private', '/private') + config.add_view(private, route_name='private', permission='foo') + config.add_route('inaccessible', '/inaccessible') + config.add_view(inaccessible, route_name='inaccessible', permission='bar') diff --git a/tests/test_integration.py b/tests/test_integration.py index 72465dc93..331542d7d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -521,6 +521,48 @@ class TestExceptionViewsApp(IntegrationBase, unittest.TestCase): self.assertTrue(b'caught' in res.body) +class TestSecurityApp(IntegrationBase, unittest.TestCase): + package = 'tests.pkgs.securityapp' + + def test_public(self): + res = self.testapp.get('/public', status=200) + self.assertEqual(res.body, b'Hello') + + def test_private_denied(self): + self.testapp.get('/private', status=403) + + def test_private_allowed(self): + self.testapp.extra_environ = {'REMOTE_USER': 'bob'} + res = self.testapp.get('/private', status=200) + self.assertEqual(res.body, b'Secret') + + def test_inaccessible(self): + self.testapp.get('/inaccessible', status=403) + self.testapp.extra_environ = {'REMOTE_USER': 'bob'} + self.testapp.get('/inaccessible', status=403) + + +class TestLegacySecurityApp(IntegrationBase, unittest.TestCase): + package = 'tests.pkgs.legacysecurityapp' + + def test_public(self): + res = self.testapp.get('/public', status=200) + self.assertEqual(res.body, b'Hello') + + def test_private_denied(self): + self.testapp.get('/private', status=403) + + def test_private_allowed(self): + self.testapp.extra_environ = {'REMOTE_USER': 'bob'} + res = self.testapp.get('/private', status=200) + self.assertEqual(res.body, b'Secret') + + def test_inaccessible(self): + self.testapp.get('/inaccessible', status=403) + self.testapp.extra_environ = {'REMOTE_USER': 'bob'} + self.testapp.get('/inaccessible', status=403) + + class TestConflictApp(unittest.TestCase): package = 'tests.pkgs.conflictapp' -- cgit v1.2.3 From 08d5eddb2932a894fac03917508da95f480bfe7d Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 12 May 2019 10:55:01 -0700 Subject: Rename request.identity to request.authenticated_identity. --- src/pyramid/security.py | 2 +- tests/test_security.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 5d8e916d7..fd05746bd 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -279,7 +279,7 @@ class ACLAllowed(ACLPermitsResult, Allowed): class SecurityAPIMixin(object): @property - def identity(self): + def authenticated_identity(self): """ Return an opaque object identifying the current user or ``None`` if no user is authenticated or there is no :term:`security policy` in effect. diff --git a/tests/test_security.py b/tests/test_security.py index 5a0307c66..2a8847f3b 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -318,7 +318,7 @@ class TestViewExecutionPermitted(unittest.TestCase): self.assertTrue(result) -class TestIdentity(unittest.TestCase): +class TestAuthenticatedIdentity(unittest.TestCase): def setUp(self): testing.setUp() @@ -327,12 +327,12 @@ class TestIdentity(unittest.TestCase): def test_identity_no_security_policy(self): request = _makeRequest() - self.assertEquals(request.identity, None) + self.assertEquals(request.authenticated_identity, None) def test_identity(self): request = _makeRequest() _registerSecurityPolicy(request.registry, 'yo') - self.assertEqual(request.identity, 'yo') + self.assertEqual(request.authenticated_identity, 'yo') class TestAuthenticatedUserId(unittest.TestCase): -- cgit v1.2.3 From 4c95ccd5e9b9657165f6ba061ee795fc4a5fcd30 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 12 May 2019 12:13:26 -0700 Subject: Narrative docs WIP --- docs/glossary.rst | 5 +- docs/narr/advanced-features.rst | 9 +- docs/narr/security.rst | 181 ++++++++++++++++++++++++---------------- src/pyramid/interfaces.py | 7 +- 4 files changed, 122 insertions(+), 80 deletions(-) diff --git a/docs/glossary.rst b/docs/glossary.rst index 8a1d27734..5d374e0ec 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -307,8 +307,9 @@ Glossary define their principals differently. identity - An identity is an opaque identifier of the user associated with the - current request. + An identity is an object identify the user associated with the + current request. The identity can be any object, but should implement a + ``__str__`` method for logging and debugging purposes. security policy A security policy in :app:`Pyramid` terms is a bit of code which has an diff --git a/docs/narr/advanced-features.rst b/docs/narr/advanced-features.rst index b169aad95..431b4f030 100644 --- a/docs/narr/advanced-features.rst +++ b/docs/narr/advanced-features.rst @@ -104,13 +104,14 @@ For example, if you want to reuse an existing application that already has a bun Authenticate Users Your Way --------------------------- -:app:`Pyramid` ships with prebuilt, well-tested authentication and authorization schemes out of the box. Using a scheme is a matter of configuration. So if you need to change approaches later, you need only update your configuration. - -In addition, the system that handles authentication and authorization is flexible and pluggable. If you want to use another security add-on, or define your own, you can. And again, you need only update your application configuration to make the change. +:app:`Pyramid` has a powerful security system that can be tailored to your +needs. Build your own security policy tailored to your needs, or use one of +the many helpers provided to easily implement common authentication and +authorization patterns. .. seealso:: - See also :ref:`enabling_authorization_policy`. + See also :ref:`writing_security_policy`. Build Trees of Resources ------------------------ diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 6b6f9a1f1..438a13380 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -6,17 +6,12 @@ Security ======== -:app:`Pyramid` provides an optional, declarative, security system. Security in -:app:`Pyramid` is separated into authentication and authorization. The two -systems communicate via :term:`principal` identifiers. Authentication is merely -the mechanism by which credentials provided in the :term:`request` are resolved -to one or more :term:`principal` identifiers. These identifiers represent the -users and groups that are in effect during the request. Authorization then -determines access based on the :term:`principal` identifiers, the requested -:term:`permission`, and a :term:`context`. +:app:`Pyramid` provides an optional, declarative security system. The system +determines the identity of the current user (authentication) and whether or not +the user has access to certain resources (authorization). The :app:`Pyramid` authorization system can prevent a :term:`view` from being -invoked based on an :term:`authorization policy`. Before a view is invoked, the +invoked based on the :term:`security policy`. Before a view is invoked, the authorization system can use the credentials in the :term:`request` along with the :term:`context` resource to determine if access will be allowed. Here's how it works at a high level: @@ -37,89 +32,124 @@ how it works at a high level: - A :term:`view callable` is located by :term:`view lookup` using the context as well as other attributes of the request. -- If an :term:`authentication policy` is in effect, it is passed the request. - It will return some number of :term:`principal` identifiers. To do this, the - policy would need to determine the authenticated :term:`userid` present in - the request. +- If a :term:`security policy` is in effect, it is passed the request and + returns the :term:`identity` of the current user. -- If an :term:`authorization policy` is in effect and the :term:`view +- If a :term:`security policy` is in effect and the :term:`view configuration` associated with the view callable that was found has a - :term:`permission` associated with it, the authorization policy is passed the - :term:`context`, some number of :term:`principal` identifiers returned by the - authentication policy, and the :term:`permission` associated with the view; - it will allow or deny access. + :term:`permission` associated with it, the policy is passed the + :term:`context`, the current :term:`identity`, and the :term:`permission` + associated with the view; it will allow or deny access. -- If the authorization policy allows access, the view callable is invoked. +- If the security policy allows access, the view callable is invoked. -- If the authorization policy denies access, the view callable is not invoked. +- If the security policy denies access, the view callable is not invoked. Instead the :term:`forbidden view` is invoked. -Authorization is enabled by modifying your application to include an -:term:`authentication policy` and :term:`authorization policy`. :app:`Pyramid` -comes with a variety of implementations of these policies. To provide maximal -flexibility, :app:`Pyramid` also allows you to create custom authentication -policies and authorization policies. +The security system is enabled by modifying your application to include a +:term:`security policy`. :app:`Pyramid` comes with a variety of helpers to +assist in the creation of this policy. .. index:: - single: authorization policy + single: security policy -.. _enabling_authorization_policy: +.. _writing_security_policy: -Enabling an Authorization Policy --------------------------------- +Writing a Security Policy +------------------------- -:app:`Pyramid` does not enable any authorization policy by default. All views -are accessible by completely anonymous users. In order to begin protecting -views from execution based on security settings, you need to enable an -authorization policy. +:app:`Pyramid` does not enable any security policy by default. All views are +accessible by completely anonymous users. In order to begin protecting views +from execution based on security settings, you need to write a security policy. -Enabling an Authorization Policy Imperatively -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Security policies are simple classes implementing a +:class:`pyramid.interfaces.ISecurityPolicy`, defined as follows: -Use the :meth:`~pyramid.config.Configurator.set_authorization_policy` method of -the :class:`~pyramid.config.Configurator` to enable an authorization policy. +.. autointerface:: pyramid.interfaces.ISecurityPolicy + :members: -You must also enable an :term:`authentication policy` in order to enable the -authorization policy. This is because authorization, in general, depends upon -authentication. Use the -:meth:`~pyramid.config.Configurator.set_authentication_policy` method during -application setup to specify the authentication policy. +A simple security policy might look like the following: -For example: +.. code-block:: python + :linenos: + + from pyramid.security import Allowed + + class SessionSecurityPolicy: + def identify(self, request): + """ Return the user ID stored in the session. """ + return request.session.get('userid') + + def permits(self, request, context, identity, permission): + """ Indiscriminately allow access to everything. """ + return Allowed('User is signed in.') + + def remember(request, userid, **kw): + request.session.get('userid') + return [] + + def forget(request): + del request.session['userid'] + return [] + +Use the :meth:`~pyramid.config.Configurator.set_security_policy` method of +the :class:`~pyramid.config.Configurator` to enforce the security policy on +your application. + +Writing a Security Policy Using Helpers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To assist in writing common security policy, Pyramid provides several helpers. +The following authentication helpers assist with implementing ``identity``, +``remember``, and ``forget``. + +* :class:`pyramid.authentication.SessionAuthenticationHelper` + +* :class:`pyramid.authentication.AuthTktCookieHelper` + +The following authorization helper assists with implementing ``permits``. + +* :class:`pyramid.authorization.ACLHelper` + +For example, our above security policy can leverage these helpers like so: .. code-block:: python :linenos: - from pyramid.config import Configurator - from pyramid.authentication import AuthTktAuthenticationPolicy - from pyramid.authorization import ACLAuthorizationPolicy - authn_policy = AuthTktAuthenticationPolicy('seekrit', hashalg='sha512') - authz_policy = ACLAuthorizationPolicy() - config = Configurator() - config.set_authentication_policy(authn_policy) - config.set_authorization_policy(authz_policy) + from pyramid.security import Allowed + from pyramid.authentication import SessionAuthenticationHelper -.. note:: The ``authentication_policy`` and ``authorization_policy`` arguments - may also be passed to their respective methods mentioned above as - :term:`dotted Python name` values, each representing the dotted name path to - a suitable implementation global defined at Python module scope. + class SessionSecurityPolicy: + def __init__(self): + self.helper = SessionAuthenticationHelper() -The above configuration enables a policy which compares the value of an "auth -ticket" cookie passed in the request's environment which contains a reference -to a single :term:`userid`, and matches that userid's :term:`principals -` against the principals present in any :term:`ACL` found in the -resource tree when attempting to call some :term:`view`. + def identify(self, request): + """ Return the user ID stored in the session. """ + return self.helper.identify(request) -While it is possible to mix and match different authentication and -authorization policies, it is an error to configure a Pyramid application with -an authentication policy but without the authorization policy or vice versa. If -you do this, you'll receive an error at application startup time. + def permits(self, request, context, identity, permission): + """ Indiscriminately allow access to everything. """ + return Allowed('User is signed in.') -.. seealso:: + def remember(request, userid, **kw): + return self.helper.remember(request, userid, **kw) + + def forget(request): + return self.helper.forget(request) - See also the :mod:`pyramid.authorization` and :mod:`pyramid.authentication` - modules for alternative implementations of authorization and authentication - policies. +Helpers are intended to be used with application-specific code, so perhaps your +authentication also queries to database to ensure the identity is valid. + +.. code-block:: python + :linenos: + + def identify(self, request): + """ Return the user ID stored in the session. """ + user_id = self.helper.identify(request) + if validate_user_id(user_id): + return user_id + else: + return None .. index:: single: permissions @@ -165,11 +195,16 @@ performed via the ``@view_config`` decorator: pass As a result of any of these various view configuration statements, if an -authorization policy is in place when the view callable is found during normal -application operations, the requesting user will need to possess the ``add`` -permission against the :term:`context` resource in order to be able to invoke -the ``blog_entry_add_view`` view. If they do not, the :term:`Forbidden view` -will be invoked. +security policy is in place when the view callable is found during normal +application operations, the security policy will be queried to see if the +requesting user is allowed the ``add`` permission within the current +:term:`context`. If the policy allows access, ``blog_entry_add_view`` will be +invoked. If not, the :term:`Forbidden view` will be invoked. + +Allowing and Denying Access With a Security Policy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The security policy's ``permits`` function is queried .. index:: pair: permission; default diff --git a/src/pyramid/interfaces.py b/src/pyramid/interfaces.py index 9dabb9cfc..bc52e324e 100644 --- a/src/pyramid/interfaces.py +++ b/src/pyramid/interfaces.py @@ -484,13 +484,18 @@ class IViewMapperFactory(Interface): class ISecurityPolicy(Interface): def identify(request): - """ Return an object identifying a trusted and verified user. """ + """ Return an object identifying a trusted and verified user. This + object may be anything, but should implement a ``__str__`` method for + logging and debugging purposes. + + """ def permits(request, context, identity, permission): """ Return an instance of :class:`pyramid.security.Allowed` if a user of the given identity is allowed the ``permission`` in the current ``context``, else return an instance of :class:`pyramid.security.Denied`. + """ def remember(request, userid, **kw): -- cgit v1.2.3 From c544a22796b02e5b86d3df9d4773274ee0aadeac Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 26 May 2019 09:51:17 -0700 Subject: First draft of narrative docs. --- docs/narr/security.rst | 311 +++++++++++++++++-------------------------------- 1 file changed, 105 insertions(+), 206 deletions(-) diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 438a13380..656ac9ca6 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -73,7 +73,7 @@ A simple security policy might look like the following: .. code-block:: python :linenos: - from pyramid.security import Allowed + from pyramid.security import Allowed, Denied class SessionSecurityPolicy: def identify(self, request): @@ -81,8 +81,11 @@ A simple security policy might look like the following: return request.session.get('userid') def permits(self, request, context, identity, permission): - """ Indiscriminately allow access to everything. """ - return Allowed('User is signed in.') + """ Allow access to everything if signed in. """ + if identity is not None: + return Allowed('User is signed in.') + else: + return Denied('User is not signed in.') def remember(request, userid, **kw): request.session.get('userid') @@ -96,6 +99,11 @@ Use the :meth:`~pyramid.config.Configurator.set_security_policy` method of the :class:`~pyramid.config.Configurator` to enforce the security policy on your application. +.. seealso:: + + For more information on implementing the ``permits`` method, see + :ref:`security_policy_permits`. + Writing a Security Policy Using Helpers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -116,7 +124,7 @@ For example, our above security policy can leverage these helpers like so: .. code-block:: python :linenos: - from pyramid.security import Allowed + from pyramid.security import Allowed, Denied from pyramid.authentication import SessionAuthenticationHelper class SessionSecurityPolicy: @@ -128,8 +136,11 @@ For example, our above security policy can leverage these helpers like so: return self.helper.identify(request) def permits(self, request, context, identity, permission): - """ Indiscriminately allow access to everything. """ - return Allowed('User is signed in.') + """ Allow access to everything if signed in. """ + if identity is not None: + return Allowed('User is signed in.') + else: + return Denied('User is not signed in.') def remember(request, userid, **kw): return self.helper.remember(request, userid, **kw) @@ -201,10 +212,47 @@ requesting user is allowed the ``add`` permission within the current :term:`context`. If the policy allows access, ``blog_entry_add_view`` will be invoked. If not, the :term:`Forbidden view` will be invoked. +.. _security_policy_permits: + Allowing and Denying Access With a Security Policy ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The security policy's ``permits`` function is queried +To determine whether access is allowed to a view with an attached permission, +Pyramid calls the ``permits`` method of the security policy. ``permits`` +should return an instance of :class:`pyramid.security.Allowed` or +:class:`pyramid.security.Denied`. Both classes accept a string as an argument, +which should detail why access was allowed or denied. + +A simple ``permits`` implementation that grants access based on a user role +might look like so: + +.. code-block:: python + :linenos: + + from pyramid.security import Allowed, Denied + + class SecurityPolicy: + def permits(self, request, context, identity, permission): + if identity is None: + return Denied('User is not signed in.') + if identity.role == 'admin': + allowed = ['read', 'write', 'delete'] + elif identity.role == 'editor': + allowed = ['read', 'write'] + else: + allowed = ['read'] + if permission in allowed: + return Allowed( + 'Access granted for user %s with role %s.', + identity, + identity.role, + ) + else: + return Denied( + 'Access denied for user %s with role %s.', + identity, + identity.role, + ) .. index:: pair: permission; default @@ -215,7 +263,7 @@ Setting a Default Permission ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If a permission is not supplied to a view configuration, the registered view -will always be executable by entirely anonymous users: any authorization policy +will always be executable by entirely anonymous users: any security policy in effect is ignored. In support of making it easier to configure applications which are "secure by @@ -252,16 +300,39 @@ When a default permission is registered: .. _assigning_acls: -Assigning ACLs to Your Resource Objects ---------------------------------------- +Implement ACL Authorization +--------------------------- + +A common way to implement authorization is using an :term:`ACL`. An ACL is a +:term:`context`-specific list of access control entries, which allow or deny +access to permissions based on a user's principals. + +Pyramid provides :class:`pyramid.authorization.ACLHelper` to assist with an +ACL-based implementation of ``permits``. Application-specific code should +construct a list of principals for the user and call +:meth:`pyramid.authorization.ACLHelper.permits`, which will return an +:class:`pyramid.security.ACLAllowed` or :class:`pyramid.security.ACLDenied` +object. An implementation might look like this: + +.. code-block:: python + :linenos: + + from pyramid.security import Everyone, Authenticated + from pyramid.authorization import ACLHelper + + class SecurityPolicy: + def permits(self, request, context, identity, permission): + principals = [Everyone] + if identity is not None: + principals.append(Authenticated) + principals.append('user:' + identity.id) + principals.append('group:' + identity.group) + return ACLHelper().permits(context, principals, permission) -When the default :app:`Pyramid` :term:`authorization policy` determines whether -a user possesses a particular permission with respect to a resource, it -examines the :term:`ACL` associated with the resource. An ACL is associated -with a resource by adding an ``__acl__`` attribute to the resource object. -This attribute can be defined on the resource *instance* if you need -instance-level security, or it can be defined on the resource *class* if you -just need type-level security. +To associate an ACL with a resource, add an ``__acl__`` attribute to the +resource object. This attribute can be defined on the resource *instance* if +you need instance-level security, or it can be defined on the resource *class* +if you just need type-level security. For example, an ACL might be attached to the resource for a blog via its class: @@ -369,11 +440,9 @@ matches. The second element is a :term:`principal`. The third argument is a permission or sequence of permission names. A principal is usually a user id, however it also may be a group id if your -authentication system provides group information and the effective -:term:`authentication policy` policy is written to respect group information. -See :ref:`extending_default_authentication_policies`. +authentication system provides group information. -Each ACE in an ACL is processed by an authorization policy *in the order +Each ACE in an ACL is processed by the ACL helper *in the order dictated by the ACL*. So if you have an ACL like this: .. code-block:: python @@ -388,9 +457,9 @@ dictated by the ACL*. So if you have an ACL like this: (Deny, Everyone, 'view'), ] -The default authorization policy will *allow* everyone the view permission, -even though later in the ACL you have an ACE that denies everyone the view -permission. On the other hand, if you have an ACL like this: +The ACL helper will *allow* everyone the view permission, even though later in +the ACL you have an ACE that denies everyone the view permission. On the other +hand, if you have an ACL like this: .. code-block:: python :linenos: @@ -404,7 +473,7 @@ permission. On the other hand, if you have an ACL like this: (Allow, Everyone, 'view'), ] -The authorization policy will deny everyone the view permission, even though +The ACL helper will deny everyone the view permission, even though later in the ACL, there is an ACE that allows everyone. The third argument in an ACE can also be a sequence of permission names instead @@ -423,6 +492,7 @@ can collapse this into a single ACE, as below. (Allow, 'group:editors', ('add', 'edit')), ] +.. _special_principals: .. index:: single: principal @@ -480,8 +550,7 @@ permissions in :data:`pyramid.security.DENY_ALL`. This ACE is often used as the *last* ACE of an ACL to explicitly cause inheriting authorization policies to "stop looking up the traversal tree" (effectively breaking any inheritance). For example, an ACL which allows *only* ``fred`` the view permission for a -particular resource, despite what inherited ACLs may say when the default -authorization policy is in effect, might look like so: +particular resource, despite what inherited ACLs may say, might look like so: .. code-block:: python :linenos: @@ -507,11 +576,10 @@ following: ACL Inheritance and Location-Awareness -------------------------------------- -While the default :term:`authorization policy` is in place, if a resource -object does not have an ACL when it is the context, its *parent* is consulted -for an ACL. If that object does not have an ACL, *its* parent is consulted for -an ACL, ad infinitum, until we've reached the root and there are no more -parents left. +While the ACL helper is in place, if a resource object does not have an ACL +when it is the context, its *parent* is consulted for an ACL. If that object +does not have an ACL, *its* parent is consulted for an ACL, ad infinitum, until +we've reached the root and there are no more parents left. In order to allow the security machinery to perform ACL inheritance, resource objects must provide *location-awareness*. Providing *location-awareness* @@ -602,184 +670,16 @@ denied or allowed. Introspecting this information in the debugger or via print statements when a call to :meth:`~pyramid.request.Request.has_permission` fails is often useful. -.. index:: - single: authentication policy (extending) - -.. _extending_default_authentication_policies: - -Extending Default Authentication Policies ------------------------------------------ - -Pyramid ships with some built in authentication policies for use in your -applications. See :mod:`pyramid.authentication` for the available policies. -They differ on their mechanisms for tracking authentication credentials between -requests, however they all interface with your application in mostly the same -way. - -Above you learned about :ref:`assigning_acls`. Each :term:`principal` used in -the :term:`ACL` is matched against the list returned from -:meth:`pyramid.interfaces.IAuthenticationPolicy.effective_principals`. -Similarly, :meth:`pyramid.request.Request.authenticated_userid` maps to -:meth:`pyramid.interfaces.IAuthenticationPolicy.authenticated_userid`. - -You may control these values by subclassing the default authentication -policies. For example, below we subclass the -:class:`pyramid.authentication.AuthTktAuthenticationPolicy` and define extra -functionality to query our database before confirming that the :term:`userid` -is valid in order to avoid blindly trusting the value in the cookie (what if -the cookie is still valid, but the user has deleted their account?). We then -use that :term:`userid` to augment the ``effective_principals`` with -information about groups and other state for that user. - -.. code-block:: python - :linenos: - - from pyramid.authentication import AuthTktAuthenticationPolicy - - class MyAuthenticationPolicy(AuthTktAuthenticationPolicy): - def authenticated_userid(self, request): - userid = self.unauthenticated_userid(request) - if userid: - if request.verify_userid_is_still_valid(userid): - return userid - - def effective_principals(self, request): - principals = [Everyone] - userid = self.authenticated_userid(request) - if userid: - principals += [Authenticated, str(userid)] - return principals - -In most instances ``authenticated_userid`` and ``effective_principals`` are -application-specific, whereas ``unauthenticated_userid``, ``remember``, and -``forget`` are generic and focused on transport and serialization of data -between consecutive requests. - -.. index:: - single: authentication policy (creating) - -.. _creating_an_authentication_policy: - -Creating Your Own Authentication Policy ---------------------------------------- - -:app:`Pyramid` ships with a number of useful out-of-the-box security policies -(see :mod:`pyramid.authentication`). However, creating your own authentication -policy is often necessary when you want to control the "horizontal and -vertical" of how your users authenticate. Doing so is a matter of creating an -instance of something that implements the following interface: - -.. code-block:: python - :linenos: - - class IAuthenticationPolicy(object): - """ An object representing a Pyramid authentication policy. """ - - def authenticated_userid(self, request): - """ Return the authenticated :term:`userid` or ``None`` if - no authenticated userid can be found. This method of the - policy should ensure that a record exists in whatever - persistent store is used related to the user (the user - should not have been deleted); if a record associated with - the current id does not exist in a persistent store, it - should return ``None``. - """ - - def unauthenticated_userid(self, request): - """ Return the *unauthenticated* userid. This method - performs the same duty as ``authenticated_userid`` but is - permitted to return the userid based only on data present - in the request; it needn't (and shouldn't) check any - persistent store to ensure that the user record related to - the request userid exists. - - This method is intended primarily a helper to assist the - ``authenticated_userid`` method in pulling credentials out - of the request data, abstracting away the specific headers, - query strings, etc that are used to authenticate the request. - """ - - def effective_principals(self, request): - """ Return a sequence representing the effective principals - typically including the :term:`userid` and any groups belonged - to by the current user, always including 'system' groups such - as ``pyramid.security.Everyone`` and - ``pyramid.security.Authenticated``. - """ - - def remember(self, request, userid, **kw): - """ Return a set of headers suitable for 'remembering' the - :term:`userid` named ``userid`` when set in a response. An - individual authentication policy and its consumers can - decide on the composition and meaning of **kw. - """ - - def forget(self, request): - """ Return a set of headers suitable for 'forgetting' the - current user on subsequent requests. - """ - -After you do so, you can pass an instance of such a class into the -:class:`~pyramid.config.Configurator.set_authentication_policy` method at -configuration time to use it. - -.. index:: - single: authorization policy (creating) - -.. _creating_an_authorization_policy: - -Creating Your Own Authorization Policy --------------------------------------- - -An authorization policy is a policy that allows or denies access after a user -has been authenticated. Most :app:`Pyramid` applications will use the default -:class:`pyramid.authorization.ACLAuthorizationPolicy`. - -However, in some cases, it's useful to be able to use a different authorization -policy than the default :class:`~pyramid.authorization.ACLAuthorizationPolicy`. -For example, it might be desirable to construct an alternate authorization -policy which allows the application to use an authorization mechanism that does -not involve :term:`ACL` objects. - -:app:`Pyramid` ships with only a single default authorization policy, so you'll -need to create your own if you'd like to use a different one. Creating and -using your own authorization policy is a matter of creating an instance of an -object that implements the following interface: - -.. code-block:: python - :linenos: - - class IAuthorizationPolicy(Interface): - """ An object representing a Pyramid authorization policy. """ - def permits(context, principals, permission): - """ Return an instance of :class:`pyramid.security.Allowed` if any - of the ``principals`` is allowed the ``permission`` in the current - ``context``, else return an instance of - :class:`pyramid.security.Denied`. - """ - - def principals_allowed_by_permission(context, permission): - """ Return a set of principal identifiers allowed by the - ``permission`` in ``context``. This behavior is optional; if you - choose to not implement it you should define this method as - something which raises a ``NotImplementedError``. This method - will only be called when the - ``pyramid.security.principals_allowed_by_permission`` API is - used.""" - -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: Admonishment Against Secret-Sharing ----------------------------------- A "secret" is required by various components of Pyramid. For example, the -:term:`authentication policy` below uses a secret value ``seekrit``:: +helper below might be used for a security policy and uses a secret value +``seekrit``:: - authn_policy = AuthTktAuthenticationPolicy('seekrit', hashalg='sha512') + helper = AuthTktCookieHelper('seekrit', hashalg='sha512') A :term:`session factory` also requires a secret:: @@ -787,9 +687,8 @@ A :term:`session factory` also requires a secret:: 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. +both the helper 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, -- cgit v1.2.3 From f9fba4fb3e6f6906821ed0f39e6d11cb0f26ce9d Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 26 May 2019 10:13:49 -0700 Subject: API docs. --- docs/api/authentication.rst | 35 +++++++++++++++++++---------------- docs/api/authorization.rst | 3 +++ src/pyramid/authentication.py | 1 + 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/api/authentication.rst b/docs/api/authentication.rst index 57f32327a..835d46de0 100644 --- a/docs/api/authentication.rst +++ b/docs/api/authentication.rst @@ -3,11 +3,28 @@ :mod:`pyramid.authentication` -------------------------------- -Authentication Policies -~~~~~~~~~~~~~~~~~~~~~~~ +Helper Classes +~~~~~~~~~~~~~~ .. automodule:: pyramid.authentication + .. autoclass:: SessionAuthenticationHelper + :members: + + .. autoclass:: AuthTktCookieHelper + :members: + + .. autoclass:: HTTPBasicCredentials + :members: + +Helper Functions +~~~~~~~~~~~~~~~~ + + .. autofunction:: extract_http_basic_credentials + +Authentication Policies +~~~~~~~~~~~~~~~~~~~~~~~ + .. autoclass:: AuthTktAuthenticationPolicy :members: :inherited-members: @@ -27,17 +44,3 @@ Authentication Policies .. autoclass:: RepozeWho1AuthenticationPolicy :members: :inherited-members: - -Helper Classes -~~~~~~~~~~~~~~ - - .. autoclass:: AuthTktCookieHelper - :members: - - .. autoclass:: HTTPBasicCredentials - :members: - -Helper Functions -~~~~~~~~~~~~~~~~ - - .. autofunction:: extract_http_basic_credentials diff --git a/docs/api/authorization.rst b/docs/api/authorization.rst index 5f5435b94..c6b3d090e 100644 --- a/docs/api/authorization.rst +++ b/docs/api/authorization.rst @@ -5,5 +5,8 @@ .. automodule:: pyramid.authorization + .. autoclass:: ACLHelper + :members: + .. autoclass:: ACLAuthorizationPolicy diff --git a/src/pyramid/authentication.py b/src/pyramid/authentication.py index c1e010516..aaae36c5c 100644 --- a/src/pyramid/authentication.py +++ b/src/pyramid/authentication.py @@ -1142,6 +1142,7 @@ class SessionAuthenticationHelper: return [] def identify(self, request): + """ Return the stored userid.""" return request.session.get(self.userid_key) -- cgit v1.2.3 From ecfd8b79c9cc68eb7462cf6e4534c300c489b50e Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 8 Jun 2019 10:50:21 -0700 Subject: Beginnings of upgrade docs. --- docs/upgrading-2.0.rst | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/upgrading-2.0.rst diff --git a/docs/upgrading-2.0.rst b/docs/upgrading-2.0.rst new file mode 100644 index 000000000..e896633e5 --- /dev/null +++ b/docs/upgrading-2.0.rst @@ -0,0 +1,42 @@ +Upgrading to Pyramid 2.0 +======================== + +Pyramid 2.0 was built to be backwards compatible with the 1.x series, so no +changes to your application should be necessary. However, some functionality +has been deprecated and it is recommended to upgrade from the legacy systems. + +.. _upgrade_auth: + +Upgrading to a Security Policy +------------------------------ + +The authentication and authorization policies of Pyramid 1.x have been merged +into a single :term:`security policy` in Pyramid 2.0. Authentication and +authorization policies will continue to function normally, however they have +been deprecated and may be removed in upcoming versions. + +A security policy should implement +:interface:`pyramid.interfaces.ISecurityPolicy`. You can set the security +policy for your application via the ``security_policy`` parameter in +:class:`pyramid.config.Configurator` or by calling +:meth:`pyramid.config.Configurator.set_security_policy`. If you set a security +policy, you cannot set a authentication or authorization policy. + +``unauthenticated_userid`` and ``authenticated_userid`` have been replaced with +the ``identify`` method. This method should return an :term:`identity`, which +can be an object of any shape, such as a dictionary or an ORM object. (It can +also be a simple user ID, as in the legacy authentication policy.) The +identity can be accessed via +:meth:`pyramid.request.Request.authenticated_identity`. If you're +using a legacy authentication policy, +:meth:`pyramid.request.Request.authenticated_identity` will return the result +of ``authenticated_userid``. + +:prop:`pyramid.request.Request.unauthenticated_userid` and +:prop:`pyramid.request.Request.authenticated_userid` are deprecated but will +continue to work as normal with legacy policies. If using a new security +policy, both properties will return the string representation of the +:term:`identity`. :prop:`pyramid.request.Request.effective_principals` is +also deprecated and will work with legacy policies, but always return a +one-element list containing the :data:`pyramid.security.Everyone` principal +when using a security policy, as there is no equivalent in the new -- cgit v1.2.3 From 3d34d658e6f88d56f8ccf6c2ad4bceaa7b23e18b Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 8 Jun 2019 11:41:02 -0700 Subject: Fix bw-compat for `unauthenticated_userid`. --- src/pyramid/security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pyramid/security.py b/src/pyramid/security.py index fd05746bd..eb5ba3e19 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -360,7 +360,7 @@ class AuthenticationAPIMixin(object): if authn is not None: return authn.unauthenticated_userid(self) elif security is not None: - return security.identify(self) + return str(security.identify(self)) else: return None -- cgit v1.2.3 From b111ba770f7a1dd8f0f62b085028ebf9e69090ee Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 8 Jun 2019 12:03:25 -0700 Subject: First draft of upgrade docs. --- docs/narr/security.rst | 4 +-- docs/upgrading-2.0.rst | 94 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 65 insertions(+), 33 deletions(-) diff --git a/docs/narr/security.rst b/docs/narr/security.rst index 656ac9ca6..a92942a25 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -300,8 +300,8 @@ When a default permission is registered: .. _assigning_acls: -Implement ACL Authorization ---------------------------- +Implementing ACL Authorization +------------------------------ A common way to implement authorization is using an :term:`ACL`. An ACL is a :term:`context`-specific list of access control entries, which allow or deny diff --git a/docs/upgrading-2.0.rst b/docs/upgrading-2.0.rst index e896633e5..506d9cf3c 100644 --- a/docs/upgrading-2.0.rst +++ b/docs/upgrading-2.0.rst @@ -1,42 +1,74 @@ Upgrading to Pyramid 2.0 ======================== -Pyramid 2.0 was built to be backwards compatible with the 1.x series, so no -changes to your application should be necessary. However, some functionality -has been deprecated and it is recommended to upgrade from the legacy systems. +Pyramid 2.0 is largely backwards compatible with the 1.x series, so minimal +changes should be necessary. However, some 1.x functionality has been +deprecated and it is recommended to upgrade from the legacy systems. -.. _upgrade_auth: +.. _upgrading_auth: -Upgrading to a Security Policy ------------------------------- +Upgrading Authentication/Authorization +-------------------------------------- The authentication and authorization policies of Pyramid 1.x have been merged into a single :term:`security policy` in Pyramid 2.0. Authentication and -authorization policies will continue to function normally, however they have -been deprecated and may be removed in upcoming versions. - -A security policy should implement -:interface:`pyramid.interfaces.ISecurityPolicy`. You can set the security -policy for your application via the ``security_policy`` parameter in -:class:`pyramid.config.Configurator` or by calling -:meth:`pyramid.config.Configurator.set_security_policy`. If you set a security -policy, you cannot set a authentication or authorization policy. - -``unauthenticated_userid`` and ``authenticated_userid`` have been replaced with -the ``identify`` method. This method should return an :term:`identity`, which -can be an object of any shape, such as a dictionary or an ORM object. (It can -also be a simple user ID, as in the legacy authentication policy.) The +authorization policies can still be used and will continue to function +normally, however they have been deprecated and support may be removed in +upcoming versions. + +The new security policy should implement +:interface:`pyramid.interfaces.ISecurityPolicy` and can be set via the +``security_policy`` argument of :class:`pyramid.config.Configurator` or +:meth:`pyramid.config.Configurator.set_security_policy`. + +The new security policy merges ``unauthenticated_userid`` and +``authenticated_userid`` into an :term:`identity` object. This object can be +of any shape, such as a simple ID string or an ORM object, but should The identity can be accessed via -:meth:`pyramid.request.Request.authenticated_identity`. If you're -using a legacy authentication policy, -:meth:`pyramid.request.Request.authenticated_identity` will return the result -of ``authenticated_userid``. +:prop:`pyramid.request.Request.authenticated_identity`. + +The concept of :term:`principals ` has been removed; the +``permits`` method is passed an identity object. This change gives much more +flexibility in authorization implementations, especially those that do not +match the ACL pattern. If you were previously using +:class:`pyramid.authorization.ACLAuthorizationPolicy`, you can achieve the same +results by writing your own ``permits`` method using +:class:`pyraid.authorization.ACLHelper`. For more details on implementing an +ACL, see :ref:`assigning_acls`. + +Pyramid does not provide any built-in security policies. Similiar +functionality of the authentication and authorization policies is now provided +by helpers, which can be utilized to easily implement your own security policy. +The functionality of the legacy authencation policies roughly correspond to the +following helpers + +* :class:`pyramid.authentication.SessionAuthenticationPolicy`: + :class:`pyramid.authentication.SessionAuthenticationHelper` +* :class:`pyramid.authentication.AuthTktAuthenticationPolicy`: + :class:`pyramid.authentication.AuthTktCookieHelper` +* :class:`pyramid.authentication.BasicAuthAuthenticationPolicy`: + Use :func:`pyramid.authentication.extract_http_basic_credentials` to retrieve + credentials. +* :class:`pyramid.authentication.RemoteUserAuthenticationPolicy`: + ``REMOTE_USER`` can be accessed with ``request.environ.get('REMOTE_USER')``. +* :class:`pyramid.authentication.RepozeWho1AuthenticationPolicy`: + No equivalent. + +For further documentation on implementing security policies, see +:ref:`writing_security_policy`. + +Behavior of the Legacy System +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Legacy authentication and authorization policies will continue to function as +normal, as well as all related :class:`pyramid.request.Request` properties. +The new :prop:`pyramid.request.Request.authenticated_identity` property will +output the same result as :prop:`pyramid.request.Request.authenticated_userid`. +If using a security policy, :prop:`pyramid.request.Request.unauthenticated_userid` and -:prop:`pyramid.request.Request.authenticated_userid` are deprecated but will -continue to work as normal with legacy policies. If using a new security -policy, both properties will return the string representation of the -:term:`identity`. :prop:`pyramid.request.Request.effective_principals` is -also deprecated and will work with legacy policies, but always return a -one-element list containing the :data:`pyramid.security.Everyone` principal -when using a security policy, as there is no equivalent in the new +:prop:`pyramid.request.Request.authenticated_userid` will both return the +string representation of the :term:`identity`. +:prop:`pyramid.request.Request.effective_principals` will always return a +one-element list containing the :data:`pyramid.security.Everyone` principal, as +there is no equivalent in the new security policy. -- cgit v1.2.3 From f54cae02910f27e2ef7df224aa8fb7b9e1df5f99 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 23 Jun 2019 11:27:27 -0700 Subject: Add a whatsnew-2.0 doc. --- docs/index.rst | 1 + docs/upgrading-2.0.rst | 74 --------------------------------------- docs/whatsnew-2.0.rst | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 74 deletions(-) delete mode 100644 docs/upgrading-2.0.rst create mode 100644 docs/whatsnew-2.0.rst diff --git a/docs/index.rst b/docs/index.rst index 4b413c16d..13ece925a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -174,6 +174,7 @@ Change History .. toctree:: :maxdepth: 1 + whatsnew-2.0 whatsnew-1.10 whatsnew-1.9 whatsnew-1.8 diff --git a/docs/upgrading-2.0.rst b/docs/upgrading-2.0.rst deleted file mode 100644 index 506d9cf3c..000000000 --- a/docs/upgrading-2.0.rst +++ /dev/null @@ -1,74 +0,0 @@ -Upgrading to Pyramid 2.0 -======================== - -Pyramid 2.0 is largely backwards compatible with the 1.x series, so minimal -changes should be necessary. However, some 1.x functionality has been -deprecated and it is recommended to upgrade from the legacy systems. - -.. _upgrading_auth: - -Upgrading Authentication/Authorization --------------------------------------- - -The authentication and authorization policies of Pyramid 1.x have been merged -into a single :term:`security policy` in Pyramid 2.0. Authentication and -authorization policies can still be used and will continue to function -normally, however they have been deprecated and support may be removed in -upcoming versions. - -The new security policy should implement -:interface:`pyramid.interfaces.ISecurityPolicy` and can be set via the -``security_policy`` argument of :class:`pyramid.config.Configurator` or -:meth:`pyramid.config.Configurator.set_security_policy`. - -The new security policy merges ``unauthenticated_userid`` and -``authenticated_userid`` into an :term:`identity` object. This object can be -of any shape, such as a simple ID string or an ORM object, but should The -identity can be accessed via -:prop:`pyramid.request.Request.authenticated_identity`. - -The concept of :term:`principals ` has been removed; the -``permits`` method is passed an identity object. This change gives much more -flexibility in authorization implementations, especially those that do not -match the ACL pattern. If you were previously using -:class:`pyramid.authorization.ACLAuthorizationPolicy`, you can achieve the same -results by writing your own ``permits`` method using -:class:`pyraid.authorization.ACLHelper`. For more details on implementing an -ACL, see :ref:`assigning_acls`. - -Pyramid does not provide any built-in security policies. Similiar -functionality of the authentication and authorization policies is now provided -by helpers, which can be utilized to easily implement your own security policy. -The functionality of the legacy authencation policies roughly correspond to the -following helpers - -* :class:`pyramid.authentication.SessionAuthenticationPolicy`: - :class:`pyramid.authentication.SessionAuthenticationHelper` -* :class:`pyramid.authentication.AuthTktAuthenticationPolicy`: - :class:`pyramid.authentication.AuthTktCookieHelper` -* :class:`pyramid.authentication.BasicAuthAuthenticationPolicy`: - Use :func:`pyramid.authentication.extract_http_basic_credentials` to retrieve - credentials. -* :class:`pyramid.authentication.RemoteUserAuthenticationPolicy`: - ``REMOTE_USER`` can be accessed with ``request.environ.get('REMOTE_USER')``. -* :class:`pyramid.authentication.RepozeWho1AuthenticationPolicy`: - No equivalent. - -For further documentation on implementing security policies, see -:ref:`writing_security_policy`. - -Behavior of the Legacy System -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Legacy authentication and authorization policies will continue to function as -normal, as well as all related :class:`pyramid.request.Request` properties. -The new :prop:`pyramid.request.Request.authenticated_identity` property will -output the same result as :prop:`pyramid.request.Request.authenticated_userid`. - -If using a security policy, -:prop:`pyramid.request.Request.unauthenticated_userid` and -:prop:`pyramid.request.Request.authenticated_userid` will both return the -string representation of the :term:`identity`. -:prop:`pyramid.request.Request.effective_principals` will always return a -one-element list containing the :data:`pyramid.security.Everyone` principal, as -there is no equivalent in the new security policy. diff --git a/docs/whatsnew-2.0.rst b/docs/whatsnew-2.0.rst new file mode 100644 index 000000000..c33f40206 --- /dev/null +++ b/docs/whatsnew-2.0.rst @@ -0,0 +1,95 @@ +What's New in Pyramid 2.0 +========================= + +This article explains the new features in :app:`Pyramid` version 2.0 as +compared to its predecessor, :app:`Pyramid` 1.10. It also documents backwards +incompatibilities between the two versions and deprecations added to +:app:`Pyramid` 2.0, as well as software dependency changes and notable +documentation additions. + +Feature Additions +----------------- + +The feature additions in Pyramid 2.0 are as follows: + +- The authentication and authorization policies of Pyramid 1.x have been merged + into a single :term:`security policy` in Pyramid 2.0. For details on how to + migrate to the new security policy, see :ref:`upgrading_auth`. + Authentication and authorization policies can still be used and will continue + to function normally for the time being. + +Deprecations +------------ + +- Authentication and authorization policies have been deprecated in favor of + the new :term:`security policy`. + +.. _upgrading_auth: + +Upgrading Authentication/Authorization +-------------------------------------- + +The authentication and authorization policies of Pyramid 1.x have been merged +into a single :term:`security policy` in Pyramid 2.0. Authentication and +authorization policies can still be used and will continue to function +normally, however they have been deprecated and support may be removed in +upcoming versions. + +The new security policy should implement +:class:`pyramid.interfaces.ISecurityPolicy` and can be set via the +``security_policy`` argument of :class:`pyramid.config.Configurator` or +:meth:`pyramid.config.Configurator.set_security_policy`. + +The new security policy merges ``unauthenticated_userid`` and +``authenticated_userid`` into an :term:`identity` object. This object can be +of any shape, such as a simple ID string or an ORM object, but should The +identity can be accessed via +:attr:`pyramid.request.Request.authenticated_identity`. + +The concept of :term:`principals ` has been removed; the +``permits`` method is passed an identity object. This change gives much more +flexibility in authorization implementations, especially those that do not +match the ACL pattern. If you were previously using +:class:`pyramid.authorization.ACLAuthorizationPolicy`, you can achieve the same +results by writing your own ``permits`` method using +:class:`pyraid.authorization.ACLHelper`. For more details on implementing an +ACL, see :ref:`assigning_acls`. + +Pyramid does not provide any built-in security policies. Similiar +functionality of the authentication and authorization policies is now provided +by helpers, which can be utilized to easily implement your own security policy. +The functionality of the legacy authencation policies roughly correspond to the +following helpers + +* :class:`pyramid.authentication.SessionAuthenticationPolicy`: + :class:`pyramid.authentication.SessionAuthenticationHelper` +* :class:`pyramid.authentication.AuthTktAuthenticationPolicy`: + :class:`pyramid.authentication.AuthTktCookieHelper` +* :class:`pyramid.authentication.BasicAuthAuthenticationPolicy`: + Use :func:`pyramid.authentication.extract_http_basic_credentials` to retrieve + credentials. +* :class:`pyramid.authentication.RemoteUserAuthenticationPolicy`: + ``REMOTE_USER`` can be accessed with ``request.environ.get('REMOTE_USER')``. +* :class:`pyramid.authentication.RepozeWho1AuthenticationPolicy`: + No equivalent. + +For further documentation on implementing security policies, see +:ref:`writing_security_policy`. + +.. _behavior_of_legacy_auth: + +Behavior of the Legacy System +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Legacy authentication and authorization policies will continue to function as +normal, as well as all related :class:`pyramid.request.Request` properties. +The new :attr:`pyramid.request.Request.authenticated_identity` property will +output the same result as :attr:`pyramid.request.Request.authenticated_userid`. + +If using a security policy, +:attr:`pyramid.request.Request.unauthenticated_userid` and +:attr:`pyramid.request.Request.authenticated_userid` will both return the +string representation of the :term:`identity`. +:attr:`pyramid.request.Request.effective_principals` will always return a +one-element list containing the :data:`pyramid.security.Everyone` principal, as +there is no equivalent in the new security policy. -- cgit v1.2.3 From f2315b9cd94fd403f1030c3199bfc76c5982d55b Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 23 Jun 2019 11:36:52 -0700 Subject: Use a table for policy => helper list. --- docs/whatsnew-2.0.rst | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/whatsnew-2.0.rst b/docs/whatsnew-2.0.rst index c33f40206..a32801058 100644 --- a/docs/whatsnew-2.0.rst +++ b/docs/whatsnew-2.0.rst @@ -59,19 +59,23 @@ Pyramid does not provide any built-in security policies. Similiar functionality of the authentication and authorization policies is now provided by helpers, which can be utilized to easily implement your own security policy. The functionality of the legacy authencation policies roughly correspond to the -following helpers - -* :class:`pyramid.authentication.SessionAuthenticationPolicy`: - :class:`pyramid.authentication.SessionAuthenticationHelper` -* :class:`pyramid.authentication.AuthTktAuthenticationPolicy`: - :class:`pyramid.authentication.AuthTktCookieHelper` -* :class:`pyramid.authentication.BasicAuthAuthenticationPolicy`: - Use :func:`pyramid.authentication.extract_http_basic_credentials` to retrieve - credentials. -* :class:`pyramid.authentication.RemoteUserAuthenticationPolicy`: - ``REMOTE_USER`` can be accessed with ``request.environ.get('REMOTE_USER')``. -* :class:`pyramid.authentication.RepozeWho1AuthenticationPolicy`: - No equivalent. +following helpers: + ++----------------------------------------------------------------+-------------------------------------------------------------------+ +| Authentication Policy | Security Policy Helper | ++================================================================+===================================================================+ +| :class:`pyramid.authentication.SessionAuthenticationPolicy` | :class:`pyramid.authentication.SessionAuthenticationHelper` | ++----------------------------------------------------------------+-------------------------------------------------------------------+ +| :class:`pyramid.authentication.AuthTktAuthenticationPolicy` | :class:`pyramid.authentication.AuthTktCookieHelper` | ++----------------------------------------------------------------+-------------------------------------------------------------------+ +| :class:`pyramid.authentication.BasicAuthAuthenticationPolicy` | Use :func:`pyramid.authentication.extract_http_basic_credentials` | +| | to retrieve credentials. | ++----------------------------------------------------------------+-------------------------------------------------------------------+ +| :class:`pyramid.authentication.RemoteUserAuthenticationPolicy` | ``REMOTE_USER`` can be accessed with | +| | ``request.environ.get('REMOTE_USER')``. | ++----------------------------------------------------------------+-------------------------------------------------------------------+ +| :class:`pyramid.authentication.RepozeWho1AuthenticationPolicy` | No equivalent. | ++----------------------------------------------------------------+-------------------------------------------------------------------+ For further documentation on implementing security policies, see :ref:`writing_security_policy`. -- cgit v1.2.3 From 5384cc6929e8abb37b49a9447bb4b9f6c85c5e49 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 23 Jun 2019 11:43:18 -0700 Subject: Act on @stevepiercy's suggestions --- docs/whatsnew-2.0.rst | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/whatsnew-2.0.rst b/docs/whatsnew-2.0.rst index a32801058..49400a937 100644 --- a/docs/whatsnew-2.0.rst +++ b/docs/whatsnew-2.0.rst @@ -42,8 +42,9 @@ The new security policy should implement The new security policy merges ``unauthenticated_userid`` and ``authenticated_userid`` into an :term:`identity` object. This object can be -of any shape, such as a simple ID string or an ORM object, but should The -identity can be accessed via +of any shape, such as a simple ID string or an ORM object, but should have a +string representation (i.e. a ``__str__`` method) useful for debugging. +The identity can be accessed via :attr:`pyramid.request.Request.authenticated_identity`. The concept of :term:`principals ` has been removed; the @@ -57,8 +58,8 @@ ACL, see :ref:`assigning_acls`. Pyramid does not provide any built-in security policies. Similiar functionality of the authentication and authorization policies is now provided -by helpers, which can be utilized to easily implement your own security policy. -The functionality of the legacy authencation policies roughly correspond to the +by helpers, which can be utilized to implement your own security policy. The +functionality of the legacy authentication policies roughly correspond to the following helpers: +----------------------------------------------------------------+-------------------------------------------------------------------+ -- cgit v1.2.3 From 5d79e3f232dfbbda1a88a3dac9f990773d1699b7 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 23 Jun 2019 12:03:25 -0700 Subject: Make sure Configator.set_security_policy is in docs. --- docs/api/config.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api/config.rst b/docs/api/config.rst index 4fe0e855d..a925f42d9 100644 --- a/docs/api/config.rst +++ b/docs/api/config.rst @@ -35,6 +35,7 @@ :methodcategory:`Using Security` + .. automethod:: set_security_policy .. automethod:: set_authentication_policy .. automethod:: set_authorization_policy .. automethod:: set_default_csrf_options -- cgit v1.2.3 From 14d569ea34409ed796296f860f03b8403859412f Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 23 Jun 2019 12:19:06 -0700 Subject: Deprecation notices. --- docs/api/request.rst | 17 +++++++++++++--- src/pyramid/config/__init__.py | 5 ++--- src/pyramid/config/security.py | 17 ++++++++++++++-- src/pyramid/interfaces.py | 6 ++++-- src/pyramid/predicates.py | 3 ++- src/pyramid/security.py | 44 ++++++++++++++++++++++++++++-------------- 6 files changed, 67 insertions(+), 25 deletions(-) diff --git a/docs/api/request.rst b/docs/api/request.rst index e7b2edc9a..8e0f77b87 100644 --- a/docs/api/request.rst +++ b/docs/api/request.rst @@ -166,7 +166,11 @@ .. attribute:: authenticated_userid - .. versionadded:: 1.5 + .. deprecated:: 2.0 + + ``authenticated_userid`` has been replaced by + :attr:`authenticated_identity` in the new security system. See + :ref:`upgrading_auth` for more information. A property which returns the :term:`userid` of the currently authenticated user or ``None`` if there is no :term:`authentication @@ -178,7 +182,11 @@ .. attribute:: unauthenticated_userid - .. versionadded:: 1.5 + .. deprecated:: 2.0 + + ``unauthenticated_userid`` has been replaced by + :attr:`authenticated_identity` in the new security system. See + :ref:`upgrading_auth` for more information. A property which returns a value which represents the *claimed* (not verified) :term:`userid` of the credentials present in the @@ -193,7 +201,10 @@ .. attribute:: effective_principals - .. versionadded:: 1.5 + .. deprecated:: 2.0 + + The new security policy has removed the concept of principals. See + :ref:`upgrading_auth` for more information. A property which returns the list of 'effective' :term:`principal` identifiers for this request. This list typically includes the diff --git a/src/pyramid/config/__init__.py b/src/pyramid/config/__init__.py index d8961268a..cf1bfad44 100644 --- a/src/pyramid/config/__init__.py +++ b/src/pyramid/config/__init__.py @@ -144,12 +144,11 @@ class Configurator( If ``authentication_policy`` is passed, it should be an instance of an :term:`authentication policy` or a :term:`dotted Python - name` to the same. (Deprecated as of Pyramid 2.0 in favor of - ``security_policy``.) + name` to the same. If ``authorization_policy`` is passed, it should be an instance of an :term:`authorization policy` or a :term:`dotted Python name` to - the same. (Deprecated as of Pyramid 2.0 in favor of ``security_policy``.) + the same. .. note:: A ``ConfigurationError`` will be raised when an authorization policy is supplied without also supplying an diff --git a/src/pyramid/config/security.py b/src/pyramid/config/security.py index e8ea0878d..a68581ff1 100644 --- a/src/pyramid/config/security.py +++ b/src/pyramid/config/security.py @@ -58,7 +58,13 @@ class SecurityConfiguratorMixin(object): @action_method def set_authentication_policy(self, policy): - """ Override the :app:`Pyramid` :term:`authentication policy` in the + """ + .. deprecated:: 2.0 + + Authentication policies have been replaced by + security policies. See :ref:`upgrading_auth` for more information. + + Override the :app:`Pyramid` :term:`authentication policy` in the current configuration. The ``policy`` argument must be an instance of an authentication policy or a :term:`dotted Python name` that points at an instance of an authentication policy. @@ -105,7 +111,13 @@ class SecurityConfiguratorMixin(object): @action_method def set_authorization_policy(self, policy): - """ Override the :app:`Pyramid` :term:`authorization policy` in the + """ + .. deprecated:: 2.0 + + Authentication policies have been replaced by + security policies. See :ref:`upgrading_auth` for more information. + + Override the :app:`Pyramid` :term:`authorization policy` in the current configuration. The ``policy`` argument must be an instance of an authorization policy or a :term:`dotted Python name` that points at an instance of an authorization policy. @@ -115,6 +127,7 @@ class SecurityConfiguratorMixin(object): Using the ``authorization_policy`` argument to the :class:`pyramid.config.Configurator` constructor can be used to achieve the same purpose. + """ def register(): diff --git a/src/pyramid/interfaces.py b/src/pyramid/interfaces.py index bc52e324e..06ab1c32e 100644 --- a/src/pyramid/interfaces.py +++ b/src/pyramid/interfaces.py @@ -518,7 +518,8 @@ class IAuthenticationPolicy(Interface): .. deprecated:: 2.0 - Use :class:`ISecurityPolicy`. + Authentication policies have been removed in favor of security + policies. See :ref:`upgrading_auth` for more information. """ @@ -577,7 +578,8 @@ class IAuthorizationPolicy(Interface): .. deprecated:: 2.0 - Use :class:`ISecurityPolicy`. + Authentication policies have been removed in favor of security + policies. See :ref:`upgrading_auth` for more information. """ diff --git a/src/pyramid/predicates.py b/src/pyramid/predicates.py index 974f41cc5..eafd4b890 100644 --- a/src/pyramid/predicates.py +++ b/src/pyramid/predicates.py @@ -294,7 +294,8 @@ class EffectivePrincipalsPredicate(object): """ .. deprecated:: 2.0 - No longer applicable with the new :term:`security policy`. + The new security system has removed the concept of principals. See + :ref:`upgrading_auth` for more information. """ diff --git a/src/pyramid/security.py b/src/pyramid/security.py index eb5ba3e19..44ae75196 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -107,7 +107,13 @@ def forget(request): def principals_allowed_by_permission(context, permission): - """ Provided a ``context`` (a resource object), and a ``permission`` + """ + .. deprecated:: 2.0 + + The new security policy has removed the concept of principals. See + :ref:`upgrading_auth` for more information. + + Provided a ``context`` (a resource object), and a ``permission`` string, if an :term:`authorization policy` is in effect, return a sequence of :term:`principal` ids that possess the permission in the ``context``. If no authorization policy is @@ -322,13 +328,16 @@ class SecurityAPIMixin(object): class AuthenticationAPIMixin(object): @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. - + """ .. deprecated:: 2.0 - Use ``request.identity`` instead. + ``authenticated_userid`` has been replaced by + :attr:`authenticated_identity` in the new security system. See + :ref:`upgrading_auth` for more information. + + 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. """ authn = _get_authentication_policy(self) @@ -342,7 +351,14 @@ class AuthenticationAPIMixin(object): @property def unauthenticated_userid(self): - """ Return an object which represents the *claimed* (not verified) user + """ + .. deprecated:: 2.0 + + ``unauthenticated_userid`` has been replaced by + :attr:`authenticated_identity` in the new security system. See + :ref:`upgrading_auth` for more information. + + 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 @@ -350,10 +366,6 @@ class AuthenticationAPIMixin(object): effective authentication policy will not ensure that a record associated with the userid exists in persistent storage. - .. deprecated:: 2.0 - - Use ``request.identity`` instead. - """ authn = _get_authentication_policy(self) security = _get_security_policy(self) @@ -366,13 +378,17 @@ class AuthenticationAPIMixin(object): @property def effective_principals(self): - """ Return the list of 'effective' :term:`principal` identifiers + """ + .. deprecated:: 2.0 + + The new security policy has removed the concept of principals. See + :ref:`upgrading_auth` for more information. + + Return the list of 'effective' :term:`principal` identifiers for the ``request``. If no :term:`authentication policy` is in effect, this will return a one-element list containing the :data:`pyramid.security.Everyone` principal. - .. deprecated:: 2.0 - """ policy = _get_authentication_policy(self) if policy is None: -- cgit v1.2.3 From 514f75fc6dfa6b7f5e58e48da229c7a23059ba31 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 23 Jun 2019 12:45:47 -0700 Subject: Add deprecation warnings. --- src/pyramid/config/security.py | 8 ++++++++ src/pyramid/predicates.py | 11 +++++++++++ src/pyramid/security.py | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/pyramid/config/security.py b/src/pyramid/config/security.py index a68581ff1..8f0a108c5 100644 --- a/src/pyramid/config/security.py +++ b/src/pyramid/config/security.py @@ -1,4 +1,5 @@ from zope.interface import implementer +from zope.deprecation import deprecate from pyramid.interfaces import ( IAuthorizationPolicy, @@ -56,6 +57,13 @@ class SecurityConfiguratorMixin(object): introspectables=(intr,), ) + @deprecate( + 'Authentication and authorization policies have been deprecated in ' + 'favor of security policies. See ' + 'https://docs.pylonsproject.org/projects/pyramid/en/latest' + '/whatsnew-2.0.html#upgrading-authentication-authorization ' + 'for more information.' + ) @action_method def set_authentication_policy(self, policy): """ diff --git a/src/pyramid/predicates.py b/src/pyramid/predicates.py index eafd4b890..a267a69a0 100644 --- a/src/pyramid/predicates.py +++ b/src/pyramid/predicates.py @@ -1,5 +1,7 @@ import re +from zope.deprecation import deprecated + from pyramid.exceptions import ConfigurationError from pyramid.csrf import check_csrf_token @@ -319,6 +321,15 @@ class EffectivePrincipalsPredicate(object): return False +deprecated( + 'EffectivePrincipalsPredicate', + 'The new security policy has removed the concept of principals. See ' + 'https://docs.pylonsproject.org/projects/pyramid/en/latest' + '/whatsnew-2.0.html#upgrading-authentication-authorization ' + 'for more information.', +) + + class Notted(object): def __init__(self, predicate): self.predicate = predicate diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 44ae75196..64e840801 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -1,4 +1,5 @@ from zope.interface import implementer, providedBy +from zope.deprecation import deprecated from pyramid.interfaces import ( ISecurityPolicy, @@ -137,6 +138,15 @@ def principals_allowed_by_permission(context, permission): return policy.principals_allowed_by_permission(context, permission) +deprecated( + 'principals_allowed_by_permission', + 'The new security policy has removed the concept of principals. See ' + 'https://docs.pylonsproject.org/projects/pyramid/en/latest' + '/whatsnew-2.0.html#upgrading-authentication-authorization ' + 'for more information.', +) + + def view_execution_permitted(context, request, name=''): """ If the view specified by ``context`` and ``name`` is protected by a :term:`permission`, check the permission associated with the @@ -395,6 +405,14 @@ class AuthenticationAPIMixin(object): return [Everyone] return policy.effective_principals(self) + effective_principals = deprecated( + effective_principals, + 'The new security policy has removed the concept of principals. See ' + 'https://docs.pylonsproject.org/projects/pyramid/en/latest' + '/whatsnew-2.0.html#upgrading-authentication-authorization ' + 'for more information.', + ) + @implementer(ISecurityPolicy) class LegacySecurityPolicy: -- cgit v1.2.3 From c69994778de79041d703fd121ec9d1aaf8a6b7c8 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 13 Jul 2019 10:10:46 -0700 Subject: Improve authn/authz API docs. --- docs/api/authentication.rst | 9 +- src/pyramid/authentication.py | 295 +++++++++++++++++++++--------------------- src/pyramid/authorization.py | 50 ++----- 3 files changed, 161 insertions(+), 193 deletions(-) diff --git a/docs/api/authentication.rst b/docs/api/authentication.rst index 835d46de0..f3a25ee64 100644 --- a/docs/api/authentication.rst +++ b/docs/api/authentication.rst @@ -14,17 +14,20 @@ Helper Classes .. autoclass:: AuthTktCookieHelper :members: - .. autoclass:: HTTPBasicCredentials - :members: - Helper Functions ~~~~~~~~~~~~~~~~ .. autofunction:: extract_http_basic_credentials + .. autoclass:: HTTPBasicCredentials + :members: + Authentication Policies ~~~~~~~~~~~~~~~~~~~~~~~ +Authentication policies have been deprecated by the new security system. See +:ref:`upgrading_auth` for more information. + .. autoclass:: AuthTktAuthenticationPolicy :members: :inherited-members: diff --git a/src/pyramid/authentication.py b/src/pyramid/authentication.py index aaae36c5c..de06fe955 100644 --- a/src/pyramid/authentication.py +++ b/src/pyramid/authentication.py @@ -428,150 +428,9 @@ class RemoteUserAuthenticationPolicy(CallbackAuthenticationPolicy): @implementer(IAuthenticationPolicy) class AuthTktAuthenticationPolicy(CallbackAuthenticationPolicy): """A :app:`Pyramid` :term:`authentication policy` which - obtains data from a Pyramid "auth ticket" cookie. - - Constructor Arguments - - ``secret`` - - 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`` - - Default: ``None``. A callback passed the userid and the - request, expected to return ``None`` if the userid doesn't - exist or a sequence of principal identifiers (possibly empty) if - the user does exist. If ``callback`` is ``None``, the userid - will be assumed to exist with no principals. Optional. - - ``cookie_name`` - - Default: ``auth_tkt``. The cookie name used - (string). Optional. - - ``secure`` - - Default: ``False``. Only send the cookie back over a secure - conn. Optional. - - ``include_ip`` - - Default: ``False``. Make the requesting IP address part of - the authentication data in the cookie. Optional. - - For IPv6 this option is not recommended. The ``mod_auth_tkt`` - specification does not specify how to handle IPv6 addresses, so using - this option in combination with IPv6 addresses may cause an - incompatible cookie. It ties the authentication ticket to that - individual's IPv6 address. - - ``timeout`` - - Default: ``None``. Maximum number of seconds which a newly - issued ticket will be considered valid. After this amount of - time, the ticket will expire (effectively logging the user - out). If this value is ``None``, the ticket never expires. - Optional. - - ``reissue_time`` - - Default: ``None``. If this parameter is set, it represents the number - of seconds that must pass before an authentication token cookie is - automatically reissued as the result of a request which requires - authentication. The duration is measured as the number of seconds - since the last auth_tkt cookie was issued and 'now'. If this value is - ``0``, a new ticket cookie will be reissued on every request which - requires authentication. - - 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 - if so. However, such a configuration is not explicitly prevented. - - Optional. - - ``max_age`` - - Default: ``None``. The max age of the auth_tkt cookie, in - seconds. This differs from ``timeout`` inasmuch as ``timeout`` - represents the lifetime of the ticket contained in the cookie, - while this value represents the lifetime of the cookie itself. - When this value is set, the cookie's ``Max-Age`` and - ``Expires`` settings will be set, allowing the auth_tkt cookie - to last between browser sessions. It is typically nonsensical - to set this to a value that is lower than ``timeout`` or - ``reissue_time``, although it is not explicitly prevented. - Optional. - - ``path`` - - Default: ``/``. The path for which the auth_tkt cookie is valid. - May be desirable if the application only serves part of a domain. - Optional. - - ``http_only`` - - Default: ``False``. Hide cookie from JavaScript by setting the - HttpOnly flag. Not honored by all browsers. - Optional. - - ``wild_domain`` - - Default: ``True``. An auth_tkt cookie will be generated for the - wildcard domain. If your site is hosted as ``example.com`` this - will make the cookie available for sites underneath ``example.com`` - such as ``www.example.com``. - Optional. - - ``parent_domain`` - - Default: ``False``. An auth_tkt cookie will be generated for the - parent domain of the current site. For example if your site is - hosted under ``www.example.com`` a cookie will be generated for - ``.example.com``. This can be useful if you have multiple sites - sharing the same domain. This option supercedes the ``wild_domain`` - option. - Optional. - - ``domain`` - - Default: ``None``. If provided the auth_tkt cookie will only be - set for this domain. This option is not compatible with ``wild_domain`` - and ``parent_domain``. - Optional. - - ``hashalg`` - - Default: ``sha512`` (the literal string). - - Any hash algorithm supported by Python's ``hashlib.new()`` function - can be used as the ``hashalg``. - - Cookies generated by different instances of AuthTktAuthenticationPolicy - using different ``hashalg`` options are not compatible. Switching the - ``hashalg`` will imply that all existing users with a valid cookie will - be required to re-login. - - Optional. - - ``debug`` - - Default: ``False``. If ``debug`` is ``True``, log messages to the - Pyramid debug logger about the results of various authentication - steps. The output from debugging is useful for reporting to maillist - or IRC channels when asking for support. - - ``samesite`` - - Default: ``'Lax'``. The 'samesite' option of the session cookie. Set - the value to ``None`` to turn off the samesite option. - - This option is available as of :app:`Pyramid` 1.10. + obtains data from a Pyramid "auth ticket" cookie. See + :class:`.AuthTktCookieHelper` for documentation of the constructor + arguments. .. versionchanged:: 1.4 @@ -823,10 +682,150 @@ def encode_ip_timestamp(ip, timestamp): class AuthTktCookieHelper(object): """ - A helper class for use in third-party authentication policy - implementations. See - :class:`pyramid.authentication.AuthTktAuthenticationPolicy` for the - meanings of the constructor arguments. + A helper class for security policies that obtains data from an "auth + ticket" cookie. + + Constructor Arguments + + ``secret`` + + 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`` + + Default: ``None``. A callback passed the userid and the + request, expected to return ``None`` if the userid doesn't + exist or a sequence of principal identifiers (possibly empty) if + the user does exist. If ``callback`` is ``None``, the userid + will be assumed to exist with no principals. Optional. + + ``cookie_name`` + + Default: ``auth_tkt``. The cookie name used + (string). Optional. + + ``secure`` + + Default: ``False``. Only send the cookie back over a secure + conn. Optional. + + ``include_ip`` + + Default: ``False``. Make the requesting IP address part of + the authentication data in the cookie. Optional. + + For IPv6 this option is not recommended. The ``mod_auth_tkt`` + specification does not specify how to handle IPv6 addresses, so using + this option in combination with IPv6 addresses may cause an + incompatible cookie. It ties the authentication ticket to that + individual's IPv6 address. + + ``timeout`` + + Default: ``None``. Maximum number of seconds which a newly + issued ticket will be considered valid. After this amount of + time, the ticket will expire (effectively logging the user + out). If this value is ``None``, the ticket never expires. + Optional. + + ``reissue_time`` + + Default: ``None``. If this parameter is set, it represents the number + of seconds that must pass before an authentication token cookie is + automatically reissued as the result of a request which requires + authentication. The duration is measured as the number of seconds + since the last auth_tkt cookie was issued and 'now'. If this value is + ``0``, a new ticket cookie will be reissued on every request which + requires authentication. + + 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 + if so. However, such a configuration is not explicitly prevented. + + Optional. + + ``max_age`` + + Default: ``None``. The max age of the auth_tkt cookie, in + seconds. This differs from ``timeout`` inasmuch as ``timeout`` + represents the lifetime of the ticket contained in the cookie, + while this value represents the lifetime of the cookie itself. + When this value is set, the cookie's ``Max-Age`` and + ``Expires`` settings will be set, allowing the auth_tkt cookie + to last between browser sessions. It is typically nonsensical + to set this to a value that is lower than ``timeout`` or + ``reissue_time``, although it is not explicitly prevented. + Optional. + + ``path`` + + Default: ``/``. The path for which the auth_tkt cookie is valid. + May be desirable if the application only serves part of a domain. + Optional. + + ``http_only`` + + Default: ``False``. Hide cookie from JavaScript by setting the + HttpOnly flag. Not honored by all browsers. + Optional. + + ``wild_domain`` + + Default: ``True``. An auth_tkt cookie will be generated for the + wildcard domain. If your site is hosted as ``example.com`` this + will make the cookie available for sites underneath ``example.com`` + such as ``www.example.com``. + Optional. + + ``parent_domain`` + + Default: ``False``. An auth_tkt cookie will be generated for the + parent domain of the current site. For example if your site is + hosted under ``www.example.com`` a cookie will be generated for + ``.example.com``. This can be useful if you have multiple sites + sharing the same domain. This option supercedes the ``wild_domain`` + option. + Optional. + + ``domain`` + + Default: ``None``. If provided the auth_tkt cookie will only be + set for this domain. This option is not compatible with ``wild_domain`` + and ``parent_domain``. + Optional. + + ``hashalg`` + + Default: ``sha512`` (the literal string). + + Any hash algorithm supported by Python's ``hashlib.new()`` function + can be used as the ``hashalg``. + + Cookies generated by different instances of AuthTktAuthenticationPolicy + using different ``hashalg`` options are not compatible. Switching the + ``hashalg`` will imply that all existing users with a valid cookie will + be required to re-login. + + Optional. + + ``debug`` + + Default: ``False``. If ``debug`` is ``True``, log messages to the + Pyramid debug logger about the results of various authentication + steps. The output from debugging is useful for reporting to maillist + or IRC channels when asking for support. + + ``samesite`` + + Default: ``'Lax'``. The 'samesite' option of the session cookie. Set + the value to ``None`` to turn off the samesite option. + """ parse_ticket = staticmethod(parse_ticket) # for tests diff --git a/src/pyramid/authorization.py b/src/pyramid/authorization.py index cb629e257..498938fd5 100644 --- a/src/pyramid/authorization.py +++ b/src/pyramid/authorization.py @@ -14,51 +14,17 @@ class ACLAuthorizationPolicy(object): """ An :term:`authorization policy` which consults an :term:`ACL` object attached to a :term:`context` to determine authorization information about a :term:`principal` or multiple principals. - If the context is part of a :term:`lineage`, the context's parents - are consulted for ACL information too. The following is true - about this security policy. - - - When checking whether the 'current' user is permitted (via the - ``permits`` method), the security policy consults the - ``context`` for an ACL first. If no ACL exists on the context, - or one does exist but the ACL does not explicitly allow or deny - access for any of the effective principals, consult the - context's parent ACL, and so on, until the lineage is exhausted - or we determine that the policy permits or denies. - - During this processing, if any :data:`pyramid.security.Deny` - ACE is found matching any principal in ``principals``, stop - processing by returning an - :class:`pyramid.security.ACLDenied` instance (equals - ``False``) immediately. If any - :data:`pyramid.security.Allow` ACE is found matching any - principal, stop processing by returning an - :class:`pyramid.security.ACLAllowed` instance (equals - ``True``) immediately. If we exhaust the context's - :term:`lineage`, and no ACE has explicitly permitted or denied - access, return an instance of - :class:`pyramid.security.ACLDenied` (equals ``False``). - - - When computing principals allowed by a permission via the - :func:`pyramid.security.principals_allowed_by_permission` - method, we compute the set of principals that are explicitly - granted the ``permission`` in the provided ``context``. We do - this by walking 'up' the object graph *from the root* to the - context. During this walking process, if we find an explicit - :data:`pyramid.security.Allow` ACE for a principal that - matches the ``permission``, the principal is included in the - allow list. However, if later in the walking process that - principal is mentioned in any :data:`pyramid.security.Deny` - ACE for the permission, the principal is removed from the allow - list. If a :data:`pyramid.security.Deny` to the principal - :data:`pyramid.security.Everyone` is encountered during the - walking process that matches the ``permission``, the allow list - is cleared for all principals encountered in previous ACLs. The - walking process ends after we've processed the any ACL directly - attached to ``context``; a set of principals is returned. + This class is a wrapper around :class:`.ACLHelper`, refer to that class for + more detailed documentation. Objects of this class implement the :class:`pyramid.interfaces.IAuthorizationPolicy` interface. + + .. deprecated:: 2.0 + + Authorization policies have been deprecated by the new security system. + See :ref:`upgrading_auth` for more information. + """ def __init__(self): -- cgit v1.2.3 From 09960927167f80bb405da52c96775241c84a8682 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sat, 13 Jul 2019 10:35:19 -0700 Subject: Improve security docs. --- docs/narr/security.rst | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/narr/security.rst b/docs/narr/security.rst index a92942a25..842cdeb05 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -10,7 +10,7 @@ Security determines the identity of the current user (authentication) and whether or not the user has access to certain resources (authorization). -The :app:`Pyramid` authorization system can prevent a :term:`view` from being +The :app:`Pyramid` security system can prevent a :term:`view` from being invoked based on the :term:`security policy`. Before a view is invoked, the authorization system can use the credentials in the :term:`request` along with the :term:`context` resource to determine if access will be allowed. Here's @@ -107,17 +107,26 @@ your application. Writing a Security Policy Using Helpers ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -To assist in writing common security policy, Pyramid provides several helpers. -The following authentication helpers assist with implementing ``identity``, -``remember``, and ``forget``. - -* :class:`pyramid.authentication.SessionAuthenticationHelper` - -* :class:`pyramid.authentication.AuthTktCookieHelper` - -The following authorization helper assists with implementing ``permits``. - -* :class:`pyramid.authorization.ACLHelper` +To assist in writing common security policies, Pyramid provides several +helpers. The following authentication helpers assist with implementing +``identity``, ``remember``, and ``forget``. + ++-------------------------------+-------------------------------------------------------------------+ +| Use Case | Helper | ++===============================+===================================================================+ +| Store the :term:`userid` | :class:`pyramid.authentication.SessionAuthenticationHelper` | +| in the :term:`session`. | | ++-------------------------------+-------------------------------------------------------------------+ +| Store the :term:`userid` | :class:`pyramid.authentication.AuthTktCookieHelper` | +| with an "auth ticket" cookie. | | ++-------------------------------+-------------------------------------------------------------------+ +| Retrieve user credentials | Use :func:`pyramid.authentication.extract_http_basic_credentials` | +| using HTTP Basic Auth. | to retrieve credentials. | ++-------------------------------+-------------------------------------------------------------------+ +| Retrieve the :term:`userid` | ``REMOTE_USER`` can be accessed with | +| from ``REMOTE_USER`` in the | ``request.environ.get('REMOTE_USER')``. | +| WSGI environment. | | ++-------------------------------+-------------------------------------------------------------------+ For example, our above security policy can leverage these helpers like so: -- cgit v1.2.3 From d2d20b92158088e7d646393733092e67120058f0 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Sun, 21 Jul 2019 09:20:44 -0700 Subject: Un-deprecate authenticated_userid. --- docs/glossary.rst | 12 ++++------- docs/whatsnew-2.0.rst | 15 ++++++++------ src/pyramid/interfaces.py | 4 ++-- src/pyramid/security.py | 52 +++++++++++++++++++++++------------------------ 4 files changed, 41 insertions(+), 42 deletions(-) diff --git a/docs/glossary.rst b/docs/glossary.rst index 5d374e0ec..b850f6e3e 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -298,18 +298,14 @@ Glossary foo` and `group bar`. userid - A *userid* is a string used to identify and authenticate - a real-world user or client. A userid is supplied to an - :term:`authentication policy` in order to discover the user's - :term:`principals `. In the authentication policies which - :app:`Pyramid` provides, the default behavior returns the user's userid as - a principal, but this is not strictly necessary in custom policies that - define their principals differently. + A *userid* is the string representation of an :term:`identity`. Just like + the identity, it should identify the user associated with the current + request. Oftentimes this is the ID of the user object in a database. identity An identity is an object identify the user associated with the current request. The identity can be any object, but should implement a - ``__str__`` method for logging and debugging purposes. + ``__str__`` method that outputs a corresponding :term:`userid`. security policy A security policy in :app:`Pyramid` terms is a bit of code which has an diff --git a/docs/whatsnew-2.0.rst b/docs/whatsnew-2.0.rst index 49400a937..446fcda21 100644 --- a/docs/whatsnew-2.0.rst +++ b/docs/whatsnew-2.0.rst @@ -40,12 +40,15 @@ The new security policy should implement ``security_policy`` argument of :class:`pyramid.config.Configurator` or :meth:`pyramid.config.Configurator.set_security_policy`. -The new security policy merges ``unauthenticated_userid`` and -``authenticated_userid`` into an :term:`identity` object. This object can be -of any shape, such as a simple ID string or an ORM object, but should have a -string representation (i.e. a ``__str__`` method) useful for debugging. -The identity can be accessed via -:attr:`pyramid.request.Request.authenticated_identity`. +The new security policy adds the concept of an :term:`identity`, which is an +object representing the user associated with the current request. The identity +can be accessed via :attr:`pyramid.request.Request.authenticated_identity`. +The object can be of any shape, such as a simple ID string or an ORM object, +but should implement a ``__str__`` method that outputs a string identifying the +current user, e.g. the ID of the user object in a database. The string +representation is return as +:attr:`pyramid.request.Request.authenticated_userid`. +(:attr:`pyramid.request.Request.unauthenticated_userid` has been deprecated.) The concept of :term:`principals ` has been removed; the ``permits`` method is passed an identity object. This change gives much more diff --git a/src/pyramid/interfaces.py b/src/pyramid/interfaces.py index 06ab1c32e..d97c3811b 100644 --- a/src/pyramid/interfaces.py +++ b/src/pyramid/interfaces.py @@ -485,8 +485,8 @@ class IViewMapperFactory(Interface): class ISecurityPolicy(Interface): def identify(request): """ Return an object identifying a trusted and verified user. This - object may be anything, but should implement a ``__str__`` method for - logging and debugging purposes. + object may be anything, but should implement a ``__str__`` method that + outputs a corresponding :term:`userid`. """ diff --git a/src/pyramid/security.py b/src/pyramid/security.py index 64e840801..08c36b457 100644 --- a/src/pyramid/security.py +++ b/src/pyramid/security.py @@ -306,6 +306,28 @@ class SecurityAPIMixin(object): return None return policy.identify(self) + @property + def authenticated_userid(self): + """ + Return the :term:`userid` of the currently authenticated user or + ``None`` if there is no :term:`security policy` in effect or there is + no currently authenticated user. + + .. versionchanged:: 2.0 + + When using the new security system, this property outputs the + string representation of the :term:`identity`. + + """ + authn = _get_authentication_policy(self) + security = _get_security_policy(self) + if authn is not None: + return authn.authenticated_userid(self) + elif security is not None: + return str(security.identify(self)) + else: + return None + 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 @@ -336,37 +358,15 @@ class SecurityAPIMixin(object): class AuthenticationAPIMixin(object): - @property - def authenticated_userid(self): - """ - .. deprecated:: 2.0 - - ``authenticated_userid`` has been replaced by - :attr:`authenticated_identity` in the new security system. See - :ref:`upgrading_auth` for more information. - - 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. - - """ - authn = _get_authentication_policy(self) - security = _get_security_policy(self) - if authn is not None: - return authn.authenticated_userid(self) - elif security is not None: - return str(security.identify(self)) - else: - return None - @property def unauthenticated_userid(self): """ .. deprecated:: 2.0 - ``unauthenticated_userid`` has been replaced by - :attr:`authenticated_identity` in the new security system. See - :ref:`upgrading_auth` for more information. + ``unauthenticated_userid`` does not have an equivalent in the new + security system. Use :attr:`.authenticated_userid` or + :attr:`.identity` instead. See :ref:`upgrading_auth` for more + information. Return an object which represents the *claimed* (not verified) user id of the credentials present in the request. ``None`` if there is no -- cgit v1.2.3 From 1cfd72e4ff20f001adabd6ea81d175a26431a3e9 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Mon, 26 Aug 2019 15:25:25 -0700 Subject: Doc fix via @mmerickel Co-Authored-By: Michael Merickel --- 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 842cdeb05..b49958b85 100644 --- a/docs/narr/security.rst +++ b/docs/narr/security.rst @@ -158,7 +158,7 @@ For example, our above security policy can leverage these helpers like so: return self.helper.forget(request) Helpers are intended to be used with application-specific code, so perhaps your -authentication also queries to database to ensure the identity is valid. +authentication also queries the database to ensure the identity is valid. .. code-block:: python :linenos: -- cgit v1.2.3 From cdb26610782176955cd8cfb0b3c3e242ca819f74 Mon Sep 17 00:00:00 2001 From: Theron Luhn Date: Mon, 26 Aug 2019 15:25:53 -0700 Subject: Doc fix via @mmerickel Co-Authored-By: Michael Merickel --- docs/glossary.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/glossary.rst b/docs/glossary.rst index b850f6e3e..36272f08c 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -303,7 +303,7 @@ Glossary request. Oftentimes this is the ID of the user object in a database. identity - An identity is an object identify the user associated with the + An identity is an object identifying the user associated with the current request. The identity can be any object, but should implement a ``__str__`` method that outputs a corresponding :term:`userid`. -- cgit v1.2.3