From 98f2d936d94d50761f32d05f8493dd7ea2258d27 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Fri, 17 Nov 2017 09:25:50 -0500 Subject: [PATCH 1/3] allow support for saml + 2-factor * python-social-auth has SOCIAL_AUTH_SAML_SECURITY_CONFIG, which is forwarded to python-saml settings configuration. This commit exposes SOCIAL_AUTH_SAML_SECURITY_CONFIG to configure tower in tower to allow users to set requestedAuthnContext, which will disable the requesting of password type auth from the idp. Thus, it's up to the idp to choose which auth to use (i.e. 2-factor). --- awx/conf/fields.py | 36 ++++++++++++++++++++++++++++ awx/sso/conf.py | 19 +++++++++++++++ awx/sso/fields.py | 58 +++++++++++++++++++++------------------------- 3 files changed, 81 insertions(+), 32 deletions(-) diff --git a/awx/conf/fields.py b/awx/conf/fields.py index 9a748593c7..8e93ad7ad3 100644 --- a/awx/conf/fields.py +++ b/awx/conf/fields.py @@ -53,6 +53,42 @@ class StringListField(ListField): return super(StringListField, self).to_representation(value) +class StringListBooleanField(ListField): + + default_error_messages = { + 'type_error': _('Expected None, True, False, a string or list of strings but got {input_type} instead.'), + } + child = CharField() + + def to_representation(self, value): + if isinstance(value, (list, tuple)): + return super(StringListBooleanField, self).to_representation(value) + elif value in fields.NullBooleanField.TRUE_VALUES: + return True + elif value in fields.NullBooleanField.FALSE_VALUES: + return False + elif value in fields.NullBooleanField.NULL_VALUES: + return None + elif isinstance(value, basestring): + return self.child.to_representation(value) + else: + self.fail('type_error', input_type=type(value)) + + def to_internal_value(self, data): + if isinstance(data, (list, tuple)): + return super(StringListBooleanField, self).to_internal_value(data) + elif data in fields.NullBooleanField.TRUE_VALUES: + return True + elif data in fields.NullBooleanField.FALSE_VALUES: + return False + elif data in fields.NullBooleanField.NULL_VALUES: + return None + elif isinstance(data, basestring): + return self.child.run_validation(data) + else: + self.fail('type_error', input_type=type(data)) + + class URLField(CharField): def __init__(self, **kwargs): diff --git a/awx/sso/conf.py b/awx/sso/conf.py index c75ec4e250..001c527f45 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -70,6 +70,11 @@ SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER = collections.OrderedDict([ ])), ]) +SOCIAL_AUTH_SAML_SECURITY_CONFIG_HELP_TEXT = _('''\ +Extra https://github.com/onelogin/python-saml#settings\ +''') + + ############################################################################### # AUTHENTICATION BACKENDS DYNAMIC SETTING ############################################################################### @@ -1061,6 +1066,20 @@ register( feature_required='enterprise_auth', ) +register( + 'SOCIAL_AUTH_SAML_SECURITY_CONFIG', + field_class=fields.SAMLSecurityField, + allow_null=True, + default=None, + label=_('SAML Security Config'), + help_text=SOCIAL_AUTH_SAML_SECURITY_CONFIG_HELP_TEXT, + category=_('SAML'), + category_slug='saml', + #placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, + placeholder=collections.OrderedDict(), + feature_required='enterprise_auth', +) + register( 'SOCIAL_AUTH_SAML_ORGANIZATION_MAP', field_class=fields.SocialOrganizationMapField, diff --git a/awx/sso/fields.py b/awx/sso/fields.py index c2400bae31..a483044464 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -345,41 +345,10 @@ class LDAPUserFlagsField(fields.DictField): return data -class LDAPDNMapField(fields.ListField): +class LDAPDNMapField(fields.StringListBooleanField): - default_error_messages = { - 'type_error': _('Expected None, True, False, a string or list of strings but got {input_type} instead.'), - } child = LDAPDNField() - def to_representation(self, value): - if isinstance(value, (list, tuple)): - return super(LDAPDNMapField, self).to_representation(value) - elif value in fields.NullBooleanField.TRUE_VALUES: - return True - elif value in fields.NullBooleanField.FALSE_VALUES: - return False - elif value in fields.NullBooleanField.NULL_VALUES: - return None - elif isinstance(value, basestring): - return self.child.to_representation(value) - else: - self.fail('type_error', input_type=type(value)) - - def to_internal_value(self, data): - if isinstance(data, (list, tuple)): - return super(LDAPDNMapField, self).to_internal_value(data) - elif data in fields.NullBooleanField.TRUE_VALUES: - return True - elif data in fields.NullBooleanField.FALSE_VALUES: - return False - elif data in fields.NullBooleanField.NULL_VALUES: - return None - elif isinstance(data, basestring): - return self.child.run_validation(data) - else: - self.fail('type_error', input_type=type(data)) - class BaseDictWithChildField(fields.DictField): @@ -649,3 +618,28 @@ class SAMLIdPField(BaseDictWithChildField): class SAMLEnabledIdPsField(fields.DictField): child = SAMLIdPField() + + +class SAMLSecurityField(fields.DictField): + + child_fields = { + 'nameIdEncrypted': fields.BooleanField(required=False), + 'authnRequestsSigned': fields.BooleanField(required=False), + 'logoutRequestSigned': fields.BooleanField(required=False), + 'logoutResponseSigned': fields.BooleanField(required=False), + 'signMetadata': fields.BooleanField(required=False), + 'wantMessagesSigned': fields.BooleanField(required=False), + 'wantAssertionsSigned': fields.BooleanField(required=False), + 'wantAssertionsEncrypted': fields.BooleanField(required=False), + 'wantNameId': fields.BooleanField(required=False), + 'wantNameIdEncrypted': fields.BooleanField(required=False), + 'wantAttributeStatement': fields.BooleanField(required=False), + 'requestedAuthnContext': fields.StringListBooleanField(required=False), + 'requestedAuthnContextComparison': fields.CharField(required=False), + 'metadataValidUntil': fields.CharField(allow_null=True, required=False), + 'metadataCacheDuration': fields.CharField(allow_null=True, required=False), + 'signatureAlgorithm': fields.CharField(allow_null=True, required=False), + 'digestAlgorithm': fields.CharField(allow_null=True, required=False), + } + allow_unknown_keys = True + From 383c3cfe3e9f3028eefbec712524e811695cd9ec Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Mon, 27 Nov 2017 15:33:20 -0500 Subject: [PATCH 2/3] add more saml fields --- awx/conf/fields.py | 22 ++++++++++++++++ awx/sso/conf.py | 62 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/awx/conf/fields.py b/awx/conf/fields.py index 8e93ad7ad3..6a17d8b0ce 100644 --- a/awx/conf/fields.py +++ b/awx/conf/fields.py @@ -136,3 +136,25 @@ class KeyValueField(DictField): if not isinstance(value, six.string_types + six.integer_types + (float,)): self.fail('invalid_child', input=value) return ret + + +class ListTuplesField(ListField): + default_error_messages = { + 'type_error': _('Expected a list of tuples but got {input_type} instead.'), + } + + def to_representation(self, value): + if isinstance(value, (list, tuple)): + return super(ListTuplesField, self).to_representation(value) + else: + self.fail('type_error', input_type=type(value)) + + def to_internal_value(self, data): + if isinstance(data, list): + for x in data: + if not isinstance(x, (list, tuple)): + self.fail('type_error', input_type=type(x)) + + return super(ListTuplesField, self).to_internal_value(data) + else: + self.fail('type_error', input_type=type(data)) diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 001c527f45..63fba340a5 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -70,11 +70,6 @@ SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER = collections.OrderedDict([ ])), ]) -SOCIAL_AUTH_SAML_SECURITY_CONFIG_HELP_TEXT = _('''\ -Extra https://github.com/onelogin/python-saml#settings\ -''') - - ############################################################################### # AUTHENTICATION BACKENDS DYNAMIC SETTING ############################################################################### @@ -1070,16 +1065,67 @@ register( 'SOCIAL_AUTH_SAML_SECURITY_CONFIG', field_class=fields.SAMLSecurityField, allow_null=True, - default=None, + default={'requestedAuthnContext': False}, label=_('SAML Security Config'), - help_text=SOCIAL_AUTH_SAML_SECURITY_CONFIG_HELP_TEXT, + help_text=_('A dict of key value pairs that are passed to the underlying' + ' python-saml security setting' + ' https://github.com/onelogin/python-saml#settings'), + category=_('SAML'), + category_slug='saml', + placeholder=collections.OrderedDict([ + ("nameIdEncrypted", False), + ("authnRequestsSigned", False), + ("logoutRequestSigned", False), + ("logoutResponseSigned", False), + ("signMetadata", False), + ("wantMessagesSigned", False), + ("wantAssertionsSigned", False), + ("wantAssertionsEncrypted", False), + ("wantNameId", True), + ("wantNameIdEncrypted", False), + ("wantAttributeStatement", True), + ("requestedAuthnContext", True), + ("requestedAuthnContextComparison", "exact"), + ("metadataValidUntil", "2015-06-26T20:00:00Z"), + ("metadataCacheDuration", "PT518400S"), + ("signatureAlgorithm", "http://www.w3.org/2000/09/xmldsig#rsa-sha1"), + ("digestAlgorithm", "http://www.w3.org/2000/09/xmldsig#sha1"), + ]), + feature_required='enterprise_auth', +) + +register( + 'SOCIAL_AUTH_SAML_SP_EXTRA', + field_class=fields.DictField, + allow_null=True, + default=None, + label=_('SAML Service Provider extra configuration data'), + help_text=_('A dict of key value pairs to be passed to the underlying' + ' python-saml Service Provider configuration setting.'), category=_('SAML'), category_slug='saml', - #placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER, placeholder=collections.OrderedDict(), feature_required='enterprise_auth', ) +register( + 'SOCIAL_AUTH_SAML_EXTRA_DATA', + field_class=fields.ListTuplesField, + allow_null=True, + default=None, + label=_('SAML IDP to extra_data attribute mapping'), + help_text=_('A list of tuples that maps IDP attributes to extra_attributes.' + ' Each attribute will be a list of values, even if only 1 value.'), + category=_('SAML'), + category_slug='saml', + placeholder=[ + ('attribute_name', 'extra_data_name_for_attribute'), + ('department', 'department'), + ('manager_full_name', 'manager_full_name') + ], + feature_required='enterprise_auth', +) + register( 'SOCIAL_AUTH_SAML_ORGANIZATION_MAP', field_class=fields.SocialOrganizationMapField, From 032318494b8e11d34dc0dbdc3f464106a04b2a64 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Wed, 29 Nov 2017 11:19:46 -0500 Subject: [PATCH 3/3] added tests for new settings field type --- awx/conf/fields.py | 57 +++++++++++--------- awx/conf/tests/unit/test_fields.py | 86 ++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 awx/conf/tests/unit/test_fields.py diff --git a/awx/conf/fields.py b/awx/conf/fields.py index 6a17d8b0ce..50aca441e0 100644 --- a/awx/conf/fields.py +++ b/awx/conf/fields.py @@ -61,32 +61,37 @@ class StringListBooleanField(ListField): child = CharField() def to_representation(self, value): - if isinstance(value, (list, tuple)): - return super(StringListBooleanField, self).to_representation(value) - elif value in fields.NullBooleanField.TRUE_VALUES: - return True - elif value in fields.NullBooleanField.FALSE_VALUES: - return False - elif value in fields.NullBooleanField.NULL_VALUES: - return None - elif isinstance(value, basestring): - return self.child.to_representation(value) - else: - self.fail('type_error', input_type=type(value)) + try: + if isinstance(value, (list, tuple)): + return super(StringListBooleanField, self).to_representation(value) + elif value in NullBooleanField.TRUE_VALUES: + return True + elif value in NullBooleanField.FALSE_VALUES: + return False + elif value in NullBooleanField.NULL_VALUES: + return None + elif isinstance(value, basestring): + return self.child.to_representation(value) + except TypeError: + pass + + self.fail('type_error', input_type=type(value)) def to_internal_value(self, data): - if isinstance(data, (list, tuple)): - return super(StringListBooleanField, self).to_internal_value(data) - elif data in fields.NullBooleanField.TRUE_VALUES: - return True - elif data in fields.NullBooleanField.FALSE_VALUES: - return False - elif data in fields.NullBooleanField.NULL_VALUES: - return None - elif isinstance(data, basestring): - return self.child.run_validation(data) - else: - self.fail('type_error', input_type=type(data)) + try: + if isinstance(data, (list, tuple)): + return super(StringListBooleanField, self).to_internal_value(data) + elif data in NullBooleanField.TRUE_VALUES: + return True + elif data in NullBooleanField.FALSE_VALUES: + return False + elif data in NullBooleanField.NULL_VALUES: + return None + elif isinstance(data, basestring): + return self.child.run_validation(data) + except TypeError: + pass + self.fail('type_error', input_type=type(data)) class URLField(CharField): @@ -140,7 +145,7 @@ class KeyValueField(DictField): class ListTuplesField(ListField): default_error_messages = { - 'type_error': _('Expected a list of tuples but got {input_type} instead.'), + 'type_error': _('Expected a list of tuples of max length 2 but got {input_type} instead.'), } def to_representation(self, value): @@ -152,7 +157,7 @@ class ListTuplesField(ListField): def to_internal_value(self, data): if isinstance(data, list): for x in data: - if not isinstance(x, (list, tuple)): + if not isinstance(x, (list, tuple)) or len(x) > 2: self.fail('type_error', input_type=type(x)) return super(ListTuplesField, self).to_internal_value(data) diff --git a/awx/conf/tests/unit/test_fields.py b/awx/conf/tests/unit/test_fields.py new file mode 100644 index 0000000000..0126723e97 --- /dev/null +++ b/awx/conf/tests/unit/test_fields.py @@ -0,0 +1,86 @@ +import pytest + +from rest_framework.fields import ValidationError +from awx.conf.fields import StringListBooleanField, ListTuplesField + + +class TestStringListBooleanField(): + + FIELD_VALUES = [ + ("hello", "hello"), + (("a", "b"), ["a", "b"]), + (["a", "b", 1, 3.13, "foo", "bar", "foobar"], ["a", "b", "1", "3.13", "foo", "bar", "foobar"]), + ("True", True), + ("TRUE", True), + ("true", True), + (True, True), + ("False", False), + ("FALSE", False), + ("false", False), + (False, False), + ("", None), + ("null", None), + ("NULL", None), + ] + + FIELD_VALUES_INVALID = [ + 1.245, + {"a": "b"}, + ] + + @pytest.mark.parametrize("value_in, value_known", FIELD_VALUES) + def test_to_internal_value_valid(self, value_in, value_known): + field = StringListBooleanField() + v = field.to_internal_value(value_in) + assert v == value_known + + @pytest.mark.parametrize("value", FIELD_VALUES_INVALID) + def test_to_internal_value_invalid(self, value): + field = StringListBooleanField() + with pytest.raises(ValidationError) as e: + field.to_internal_value(value) + assert e.value.detail[0] == "Expected None, True, False, a string or list " \ + "of strings but got {} instead.".format(type(value)) + + @pytest.mark.parametrize("value_in, value_known", FIELD_VALUES) + def test_to_representation_valid(self, value_in, value_known): + field = StringListBooleanField() + v = field.to_representation(value_in) + assert v == value_known + + @pytest.mark.parametrize("value", FIELD_VALUES_INVALID) + def test_to_representation_invalid(self, value): + field = StringListBooleanField() + with pytest.raises(ValidationError) as e: + field.to_representation(value) + assert e.value.detail[0] == "Expected None, True, False, a string or list " \ + "of strings but got {} instead.".format(type(value)) + + +class TestListTuplesField(): + + FIELD_VALUES = [ + ([('a', 'b'), ('abc', '123')], [("a", "b"), ("abc", "123")]), + ] + + FIELD_VALUES_INVALID = [ + ("abc", type("abc")), + ([('a', 'b', 'c'), ('abc', '123', '456')], type(('a',))), + (['a', 'b'], type('a')), + (123, type(123)), + ] + + @pytest.mark.parametrize("value_in, value_known", FIELD_VALUES) + def test_to_internal_value_valid(self, value_in, value_known): + field = ListTuplesField() + v = field.to_internal_value(value_in) + assert v == value_known + + @pytest.mark.parametrize("value, t", FIELD_VALUES_INVALID) + def test_to_internal_value_invalid(self, value, t): + field = ListTuplesField() + with pytest.raises(ValidationError) as e: + field.to_internal_value(value) + assert e.value.detail[0] == "Expected a list of tuples of max length 2 " \ + "but got {} instead.".format(t) +