diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 3a1ec97993..9bab230595 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -286,6 +286,7 @@ REST_FRAMEWORK = { AUTHENTICATION_BACKENDS = ( 'awx.sso.backends.LDAPBackend', 'awx.sso.backends.RADIUSBackend', + 'awx.sso.backends.TACACSPlusBackend', 'social.backends.google.GoogleOAuth2', 'social.backends.github.GithubOAuth2', 'social.backends.github.GithubOrganizationOAuth2', diff --git a/awx/sso/backends.py b/awx/sso/backends.py index 9a37ea8bf1..0e10ffbf75 100644 --- a/awx/sso/backends.py +++ b/awx/sso/backends.py @@ -22,6 +22,9 @@ from django_auth_ldap.backend import populate_user # radiusauth from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend +# tacacs+ auth +import tacacs_plus + # social from social.backends.saml import OID_USERID from social.backends.saml import SAMLAuth as BaseSAMLAuth @@ -151,6 +154,60 @@ class RADIUSBackend(BaseRADIUSBackend): return user +class TACACSPlusBackend(object): + ''' + Custom TACACS+ auth backend for AWX + ''' + def _get_or_set_user(self, username, password): + user, created = User.objects.get_or_create( + username=username, + defaults={'is_superuser': False}, + ) + if created: + logger.debug("Created TACACS+ user %s" % (username,)) + if password is not None: + user.set_unusable_password() + user.save() + return user + + def authenticate(self, username, password): + if not django_settings.TACACSPLUS_HOST: + return None + if not feature_enabled('enterprise_auth'): + logger.error("Unable to authenticate, license does not support TACACS+ authentication") + return None + try: + # Upstream TACACS+ client does not accept non-string, so convert if needed. + auth = tacacs_plus.TACACSClient( + django_settings.TACACSPLUS_HOST.encode('utf-8'), + django_settings.TACACSPLUS_PORT, + django_settings.TACACSPLUS_SECRET.encode('utf-8'), + timeout=django_settings.TACACSPLUS_SESSION_TIMEOUT, + ).authenticate( + username.encode('utf-8'), password.encode('utf-8'), + tacacs_plus.TAC_PLUS_AUTHEN_TYPES[django_settings.TACACSPLUS_AUTH_PROTOCOL], + ) + except Exception as e: + logger.exception("TACACS+ Authentication Error: %s" % (e.message,)) + return None + if auth.valid: + return self._get_or_set_user(username, password) + else: + return None + return None + + def get_user(self, user_id): + if not django_settings.TACACSPLUS_HOST: + return None + if not feature_enabled('enterprise_auth'): + logger.error("Unable to get user, license does not support TACACS+ authentication") + return None + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist: + return None + + class TowerSAMLIdentityProvider(BaseSAMLIdentityProvider): ''' Custom Identity Provider to make attributes to what we expect. diff --git a/awx/sso/conf.py b/awx/sso/conf.py index fa1ce5ccc7..d40ea3a5fd 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -531,7 +531,7 @@ register( default='', label=_('TACACS+ Server'), help_text=_('Hostname of TACACS+ server.'), - category=_('TACACSPLUS'), + category=_('TACACS+'), category_slug='tacacsplus', feature_required='enterprise_auth', ) @@ -544,19 +544,20 @@ register( default=49, label=_('TACACS+ Port'), help_text=_('Port number of TACACS+ server.'), - category=_('TACACSPLUS'), + category=_('TACACS+'), category_slug='tacacsplus', feature_required='enterprise_auth', ) register( 'TACACSPLUS_SECRET', - field_class=fields.TACACSPLUSSecretField, + field_class=fields.CharField, allow_blank=True, default='', + validators=[validate_tacacsplus_disallow_nonascii], label=_('TACACS+ Secret'), help_text=_('Shared secret for authenticating to TACACS+ server.'), - category=_('TACACSPLUS'), + category=_('TACACS+'), category_slug='tacacsplus', feature_required='enterprise_auth', encrypted=True, @@ -568,8 +569,8 @@ register( min_value=0, default=5, label=_('TACACS+ Auth Session Timeout'), - help_text=_('TACACS+ session timeout value in seconds. Set to 0 to cancel timeout.'), - category=_('TACACSPLUS'), + help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'), + category=_('TACACS+'), category_slug='tacacsplus', feature_required='enterprise_auth', ) @@ -581,7 +582,7 @@ register( default='ascii', label=_('TACACS+ Authentication Protocol'), help_text=_('Choose the authentication protocol used by TACACS+ client.'), - category=_('TACACSPLUS'), + category=_('TACACS+'), category_slug='tacacsplus', feature_required='enterprise_auth', ) diff --git a/awx/sso/fields.py b/awx/sso/fields.py index a94cff3f7d..338178b288 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -470,11 +470,6 @@ class RADIUSSecretField(fields.CharField): return value -class TACACSPLUSSecretField(RADIUSSecretField): - - pass - - class SocialMapStringRegexField(fields.CharField): def to_representation(self, value): diff --git a/awx/sso/tests/conftest.py b/awx/sso/tests/conftest.py new file mode 100644 index 0000000000..2051c3fdaa --- /dev/null +++ b/awx/sso/tests/conftest.py @@ -0,0 +1,38 @@ +import pytest + +from django.contrib.auth.models import User + +from awx.sso.backends import TACACSPlusBackend + + +@pytest.fixture +def tacacsplus_backend(): + return TACACSPlusBackend() + + +@pytest.fixture +def existing_tacacsplus_user(): + try: + user = User.objects.get(username="foo") + except User.DoesNotExist: + user = User(username="foo") + user.save() + return user + + +@pytest.fixture +def feature_enabled(): + def func(feature): + def inner(name): + return name == feature + return inner + return func + + +@pytest.fixture +def feature_disabled(): + def func(feature): + def inner(name): + return False + return inner + return func diff --git a/awx/sso/tests/functional/test_tacacsplus.py b/awx/sso/tests/functional/test_tacacsplus.py new file mode 100644 index 0000000000..541c163294 --- /dev/null +++ b/awx/sso/tests/functional/test_tacacsplus.py @@ -0,0 +1,24 @@ +import pytest +import mock + + +@pytest.mark.django_db +def test_fetch_user_if_exist(tacacsplus_backend, existing_tacacsplus_user): + new_user = tacacsplus_backend._get_or_set_user("foo", "password") + assert new_user == existing_tacacsplus_user + + +@pytest.mark.django_db +def test_create_user_if_not_exist(tacacsplus_backend, existing_tacacsplus_user): + with mock.patch('awx.sso.backends.logger') as mocked_logger: + new_user = tacacsplus_backend._get_or_set_user("bar", "password") + mocked_logger.debug.assert_called_once_with( + 'Created TACACS+ user bar' + ) + assert new_user != existing_tacacsplus_user + + +@pytest.mark.django_db +def test_created_user_has_no_usable_password(tacacsplus_backend): + new_user = tacacsplus_backend._get_or_set_user("bar", "password") + assert not new_user.has_usable_password() diff --git a/awx/sso/tests/unit/test_tacacsplus.py b/awx/sso/tests/unit/test_tacacsplus.py new file mode 100644 index 0000000000..33625a8dd7 --- /dev/null +++ b/awx/sso/tests/unit/test_tacacsplus.py @@ -0,0 +1,65 @@ +import mock + + +def test_empty_host_fails_auth(tacacsplus_backend): + with mock.patch('awx.sso.backends.django_settings') as settings: + settings.TACACSPLUS_HOST = '' + ret_user = tacacsplus_backend.authenticate(u"user", u"pass") + assert ret_user is None + + +def test_disabled_enterprise_auth_fails_auth(tacacsplus_backend, feature_disabled): + with mock.patch('awx.sso.backends.django_settings') as settings,\ + mock.patch('awx.sso.backends.logger') as logger,\ + mock.patch('awx.sso.backends.feature_enabled', feature_disabled('enterprise_auth')): + settings.TACACSPLUS_HOST = 'localhost' + ret_user = tacacsplus_backend.authenticate(u"user", u"pass") + assert ret_user is None + logger.error.assert_called_once_with( + "Unable to authenticate, license does not support TACACS+ authentication" + ) + + +def test_client_raises_exception(tacacsplus_backend, feature_enabled): + client = mock.MagicMock() + client.authenticate.side_effect=Exception("foo") + with mock.patch('awx.sso.backends.django_settings') as settings,\ + mock.patch('awx.sso.backends.logger') as logger,\ + mock.patch('awx.sso.backends.feature_enabled', feature_enabled('enterprise_auth')),\ + mock.patch('tacacs_plus.TACACSClient', return_value=client): + settings.TACACSPLUS_HOST = 'localhost' + settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' + ret_user = tacacsplus_backend.authenticate(u"user", u"pass") + assert ret_user is None + logger.exception.assert_called_once_with( + "TACACS+ Authentication Error: foo" + ) + + +def test_client_return_invalid_fails_auth(tacacsplus_backend, feature_enabled): + auth = mock.MagicMock() + auth.valid = False + client = mock.MagicMock() + client.authenticate.return_value = auth + with mock.patch('awx.sso.backends.django_settings') as settings,\ + mock.patch('awx.sso.backends.feature_enabled', feature_enabled('enterprise_auth')),\ + mock.patch('tacacs_plus.TACACSClient', return_value=client): + settings.TACACSPLUS_HOST = 'localhost' + settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' + ret_user = tacacsplus_backend.authenticate(u"user", u"pass") + assert ret_user is None + + +def test_client_return_valid_passes_auth(tacacsplus_backend, feature_enabled): + auth = mock.MagicMock() + auth.valid = True + client = mock.MagicMock() + client.authenticate.return_value = auth + with mock.patch('awx.sso.backends.django_settings') as settings,\ + mock.patch('awx.sso.backends.feature_enabled', feature_enabled('enterprise_auth')),\ + mock.patch('tacacs_plus.TACACSClient', return_value=client),\ + mock.patch.object(tacacsplus_backend, '_get_or_set_user', return_value="user"): + settings.TACACSPLUS_HOST = 'localhost' + settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii' + ret_user = tacacsplus_backend.authenticate(u"user", u"pass") + assert ret_user == "user" diff --git a/awx/sso/validators.py b/awx/sso/validators.py index dd201f3e67..5c46dc4c83 100644 --- a/awx/sso/validators.py +++ b/awx/sso/validators.py @@ -10,7 +10,8 @@ from django.utils.translation import ugettext_lazy as _ __all__ = ['validate_ldap_dn', 'validate_ldap_dn_with_user', 'validate_ldap_bind_dn', 'validate_ldap_filter', - 'validate_ldap_filter_with_user'] + 'validate_ldap_filter_with_user', + 'validate_tacacsplus_disallow_nonascii'] def validate_ldap_dn(value, with_user=False): @@ -58,3 +59,8 @@ def validate_ldap_filter(value, with_user=False): def validate_ldap_filter_with_user(value): validate_ldap_filter(value, with_user=True) + + +def validate_tacacsplus_disallow_nonascii(value): + if not all(ord(c) < 128 for c in value): + raise ValidationError(_('TACACS+ secret does not allow non-ascii characters'))