mirror of
https://github.com/ansible/awx.git
synced 2026-02-26 07:26:03 -03:30
Implement tacacs+ auth backend.
This commit is contained in:
@@ -286,6 +286,7 @@ REST_FRAMEWORK = {
|
|||||||
AUTHENTICATION_BACKENDS = (
|
AUTHENTICATION_BACKENDS = (
|
||||||
'awx.sso.backends.LDAPBackend',
|
'awx.sso.backends.LDAPBackend',
|
||||||
'awx.sso.backends.RADIUSBackend',
|
'awx.sso.backends.RADIUSBackend',
|
||||||
|
'awx.sso.backends.TACACSPlusBackend',
|
||||||
'social.backends.google.GoogleOAuth2',
|
'social.backends.google.GoogleOAuth2',
|
||||||
'social.backends.github.GithubOAuth2',
|
'social.backends.github.GithubOAuth2',
|
||||||
'social.backends.github.GithubOrganizationOAuth2',
|
'social.backends.github.GithubOrganizationOAuth2',
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ from django_auth_ldap.backend import populate_user
|
|||||||
# radiusauth
|
# radiusauth
|
||||||
from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend
|
from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend
|
||||||
|
|
||||||
|
# tacacs+ auth
|
||||||
|
import tacacs_plus
|
||||||
|
|
||||||
# social
|
# social
|
||||||
from social.backends.saml import OID_USERID
|
from social.backends.saml import OID_USERID
|
||||||
from social.backends.saml import SAMLAuth as BaseSAMLAuth
|
from social.backends.saml import SAMLAuth as BaseSAMLAuth
|
||||||
@@ -151,6 +154,60 @@ class RADIUSBackend(BaseRADIUSBackend):
|
|||||||
return user
|
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):
|
class TowerSAMLIdentityProvider(BaseSAMLIdentityProvider):
|
||||||
'''
|
'''
|
||||||
Custom Identity Provider to make attributes to what we expect.
|
Custom Identity Provider to make attributes to what we expect.
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ register(
|
|||||||
default='',
|
default='',
|
||||||
label=_('TACACS+ Server'),
|
label=_('TACACS+ Server'),
|
||||||
help_text=_('Hostname of TACACS+ server.'),
|
help_text=_('Hostname of TACACS+ server.'),
|
||||||
category=_('TACACSPLUS'),
|
category=_('TACACS+'),
|
||||||
category_slug='tacacsplus',
|
category_slug='tacacsplus',
|
||||||
feature_required='enterprise_auth',
|
feature_required='enterprise_auth',
|
||||||
)
|
)
|
||||||
@@ -544,19 +544,20 @@ register(
|
|||||||
default=49,
|
default=49,
|
||||||
label=_('TACACS+ Port'),
|
label=_('TACACS+ Port'),
|
||||||
help_text=_('Port number of TACACS+ server.'),
|
help_text=_('Port number of TACACS+ server.'),
|
||||||
category=_('TACACSPLUS'),
|
category=_('TACACS+'),
|
||||||
category_slug='tacacsplus',
|
category_slug='tacacsplus',
|
||||||
feature_required='enterprise_auth',
|
feature_required='enterprise_auth',
|
||||||
)
|
)
|
||||||
|
|
||||||
register(
|
register(
|
||||||
'TACACSPLUS_SECRET',
|
'TACACSPLUS_SECRET',
|
||||||
field_class=fields.TACACSPLUSSecretField,
|
field_class=fields.CharField,
|
||||||
allow_blank=True,
|
allow_blank=True,
|
||||||
default='',
|
default='',
|
||||||
|
validators=[validate_tacacsplus_disallow_nonascii],
|
||||||
label=_('TACACS+ Secret'),
|
label=_('TACACS+ Secret'),
|
||||||
help_text=_('Shared secret for authenticating to TACACS+ server.'),
|
help_text=_('Shared secret for authenticating to TACACS+ server.'),
|
||||||
category=_('TACACSPLUS'),
|
category=_('TACACS+'),
|
||||||
category_slug='tacacsplus',
|
category_slug='tacacsplus',
|
||||||
feature_required='enterprise_auth',
|
feature_required='enterprise_auth',
|
||||||
encrypted=True,
|
encrypted=True,
|
||||||
@@ -568,8 +569,8 @@ register(
|
|||||||
min_value=0,
|
min_value=0,
|
||||||
default=5,
|
default=5,
|
||||||
label=_('TACACS+ Auth Session Timeout'),
|
label=_('TACACS+ Auth Session Timeout'),
|
||||||
help_text=_('TACACS+ session timeout value in seconds. Set to 0 to cancel timeout.'),
|
help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'),
|
||||||
category=_('TACACSPLUS'),
|
category=_('TACACS+'),
|
||||||
category_slug='tacacsplus',
|
category_slug='tacacsplus',
|
||||||
feature_required='enterprise_auth',
|
feature_required='enterprise_auth',
|
||||||
)
|
)
|
||||||
@@ -581,7 +582,7 @@ register(
|
|||||||
default='ascii',
|
default='ascii',
|
||||||
label=_('TACACS+ Authentication Protocol'),
|
label=_('TACACS+ Authentication Protocol'),
|
||||||
help_text=_('Choose the authentication protocol used by TACACS+ client.'),
|
help_text=_('Choose the authentication protocol used by TACACS+ client.'),
|
||||||
category=_('TACACSPLUS'),
|
category=_('TACACS+'),
|
||||||
category_slug='tacacsplus',
|
category_slug='tacacsplus',
|
||||||
feature_required='enterprise_auth',
|
feature_required='enterprise_auth',
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -470,11 +470,6 @@ class RADIUSSecretField(fields.CharField):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class TACACSPLUSSecretField(RADIUSSecretField):
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SocialMapStringRegexField(fields.CharField):
|
class SocialMapStringRegexField(fields.CharField):
|
||||||
|
|
||||||
def to_representation(self, value):
|
def to_representation(self, value):
|
||||||
|
|||||||
38
awx/sso/tests/conftest.py
Normal file
38
awx/sso/tests/conftest.py
Normal file
@@ -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
|
||||||
24
awx/sso/tests/functional/test_tacacsplus.py
Normal file
24
awx/sso/tests/functional/test_tacacsplus.py
Normal file
@@ -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()
|
||||||
65
awx/sso/tests/unit/test_tacacsplus.py
Normal file
65
awx/sso/tests/unit/test_tacacsplus.py
Normal file
@@ -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"
|
||||||
@@ -10,7 +10,8 @@ from django.utils.translation import ugettext_lazy as _
|
|||||||
|
|
||||||
__all__ = ['validate_ldap_dn', 'validate_ldap_dn_with_user',
|
__all__ = ['validate_ldap_dn', 'validate_ldap_dn_with_user',
|
||||||
'validate_ldap_bind_dn', 'validate_ldap_filter',
|
'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):
|
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):
|
def validate_ldap_filter_with_user(value):
|
||||||
validate_ldap_filter(value, with_user=True)
|
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'))
|
||||||
|
|||||||
Reference in New Issue
Block a user