Remove TACACS+ authentication (#15547)

Remove TACACS+ authentication from AWX.

Co-authored-by: Hao Liu <44379968+TheRealHaoLiu@users.noreply.github.com>
This commit is contained in:
Djebran Lezzoum
2024-10-02 15:50:17 +02:00
committed by jessicamack
parent f22b192fb4
commit e4c11561cc
27 changed files with 31 additions and 571 deletions

View File

@@ -0,0 +1,26 @@
from django.db import migrations
TACACS_PLUS_AUTH_CONF_KEYS = [
'TACACSPLUS_HOST',
'TACACSPLUS_PORT',
'TACACSPLUS_SECRET',
'TACACSPLUS_SESSION_TIMEOUT',
'TACACSPLUS_AUTH_PROTOCOL',
'TACACSPLUS_REM_ADDR',
]
def remove_tacacs_plus_auth_conf(apps, scheme_editor):
setting = apps.get_model('conf', 'Setting')
setting.objects.filter(key__in=TACACS_PLUS_AUTH_CONF_KEYS).delete()
class Migration(migrations.Migration):
dependencies = [
('conf', '0010_change_to_JSONField'),
]
operations = [
migrations.RunPython(remove_tacacs_plus_auth_conf),
]

View File

@@ -98,21 +98,6 @@ def test_radius_settings(get, put, patch, delete, admin, settings):
assert settings.RADIUS_SECRET == ''
@pytest.mark.django_db
def test_tacacsplus_settings(get, put, patch, admin):
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'tacacsplus'})
response = get(url, user=admin, expect=200)
put(url, user=admin, data=response.data, expect=200)
patch(url, user=admin, data={'TACACSPLUS_SECRET': 'mysecret'}, expect=200)
patch(url, user=admin, data={'TACACSPLUS_SECRET': ''}, expect=200)
patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost'}, expect=400)
patch(url, user=admin, data={'TACACSPLUS_SECRET': 'mysecret'}, expect=200)
patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost'}, expect=200)
patch(url, user=admin, data={'TACACSPLUS_HOST': '', 'TACACSPLUS_SECRET': ''}, expect=200)
patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost', 'TACACSPLUS_SECRET': ''}, expect=400)
patch(url, user=admin, data={'TACACSPLUS_HOST': 'localhost', 'TACACSPLUS_SECRET': 'mysecret'}, expect=200)
@pytest.mark.django_db
def test_ui_settings(get, put, patch, delete, admin):
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ui'})

View File

@@ -393,7 +393,6 @@ REST_FRAMEWORK = {
AUTHENTICATION_BACKENDS = (
'awx.sso.backends.RADIUSBackend',
'awx.sso.backends.TACACSPlusBackend',
'social_core.backends.google.GoogleOAuth2',
'social_core.backends.github.GithubOAuth2',
'social_core.backends.github.GithubOrganizationOAuth2',
@@ -424,15 +423,6 @@ RADIUS_SERVER = ''
RADIUS_PORT = 1812
RADIUS_SECRET = ''
# TACACS+ settings (default host to empty string to skip using TACACS+ auth).
# Note: These settings may be overridden by database settings.
TACACSPLUS_HOST = ''
TACACSPLUS_PORT = 49
TACACSPLUS_SECRET = ''
TACACSPLUS_SESSION_TIMEOUT = 5
TACACSPLUS_AUTH_PROTOCOL = 'ascii'
TACACSPLUS_REM_ADDR = False
# Enable / Disable HTTP Basic Authentication used in the API browser
# Note: Session limits are not enforced when using HTTP Basic Authentication.
# Note: This setting may be overridden by database settings.

View File

@@ -13,9 +13,6 @@ from django.http import HttpResponse
# radiusauth
from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend
# tacacs+ auth
import tacacs_plus
# social
from social_core.backends.saml import OID_USERID
from social_core.backends.saml import SAMLAuth as BaseSAMLAuth
@@ -69,54 +66,6 @@ class RADIUSBackend(BaseRADIUSBackend):
return _get_or_set_enterprise_user(force_str(username), force_str(password), 'radius')
class TACACSPlusBackend(object):
"""
Custom TACACS+ auth backend for AWX
"""
def authenticate(self, request, username, password):
if not django_settings.TACACSPLUS_HOST:
return None
try:
# Upstream TACACS+ client does not accept non-string, so convert if needed.
tacacs_client = tacacs_plus.TACACSClient(
django_settings.TACACSPLUS_HOST,
django_settings.TACACSPLUS_PORT,
django_settings.TACACSPLUS_SECRET,
timeout=django_settings.TACACSPLUS_SESSION_TIMEOUT,
)
auth_kwargs = {'authen_type': tacacs_plus.TAC_PLUS_AUTHEN_TYPES[django_settings.TACACSPLUS_AUTH_PROTOCOL]}
if django_settings.TACACSPLUS_AUTH_PROTOCOL:
client_ip = self._get_client_ip(request)
if client_ip:
auth_kwargs['rem_addr'] = client_ip
auth = tacacs_client.authenticate(username, password, **auth_kwargs)
except Exception as e:
logger.exception("TACACS+ Authentication Error: %s" % str(e))
return None
if auth.valid:
return _get_or_set_enterprise_user(username, password, 'tacacs+')
def get_user(self, user_id):
if not django_settings.TACACSPLUS_HOST:
return None
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None
def _get_client_ip(self, request):
if not request or not hasattr(request, 'META'):
return None
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
class TowerSAMLIdentityProvider(BaseSAMLIdentityProvider):
"""
Custom Identity Provider to make attributes to what we expect.

View File

@@ -186,11 +186,9 @@ def get_external_account(user):
def is_remote_auth_enabled():
from django.conf import settings
# Append Radius, TACACS+ and SAML options
settings_that_turn_on_remote_auth = [
'SOCIAL_AUTH_SAML_ENABLED_IDPS',
'RADIUS_SERVER',
'TACACSPLUS_HOST',
]
# Also include any SOCAIL_AUTH_*KEY (except SAML)
for social_auth_key in dir(settings):

View File

@@ -7,11 +7,8 @@ from django.conf import settings
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
# Django REST Framework
from rest_framework import serializers
# AWX
from awx.conf import register, register_validate, fields
from awx.conf import register, fields
from awx.sso.fields import (
AuthenticationBackendsField,
SAMLContactField,
@@ -25,7 +22,6 @@ from awx.sso.fields import (
SocialTeamMapField,
)
from awx.main.validators import validate_private_key, validate_certificate
from awx.sso.validators import validate_tacacsplus_disallow_nonascii # noqa
class SocialAuthCallbackURL(object):
@@ -187,79 +183,6 @@ if settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT:
encrypted=True,
)
###############################################################################
# TACACSPLUS AUTHENTICATION SETTINGS
###############################################################################
register(
'TACACSPLUS_HOST',
field_class=fields.CharField,
allow_blank=True,
default='',
label=_('TACACS+ Server'),
help_text=_('Hostname of TACACS+ server.'),
category=_('TACACS+'),
category_slug='tacacsplus',
)
register(
'TACACSPLUS_PORT',
field_class=fields.IntegerField,
min_value=1,
max_value=65535,
default=49,
label=_('TACACS+ Port'),
help_text=_('Port number of TACACS+ server.'),
category=_('TACACS+'),
category_slug='tacacsplus',
)
register(
'TACACSPLUS_SECRET',
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=_('TACACS+'),
category_slug='tacacsplus',
encrypted=True,
)
register(
'TACACSPLUS_SESSION_TIMEOUT',
field_class=fields.IntegerField,
min_value=0,
default=5,
label=_('TACACS+ Auth Session Timeout'),
help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'),
category=_('TACACS+'),
category_slug='tacacsplus',
unit=_('seconds'),
)
register(
'TACACSPLUS_AUTH_PROTOCOL',
field_class=fields.ChoiceField,
choices=['ascii', 'pap'],
default='ascii',
label=_('TACACS+ Authentication Protocol'),
help_text=_('Choose the authentication protocol used by TACACS+ client.'),
category=_('TACACS+'),
category_slug='tacacsplus',
)
register(
'TACACSPLUS_REM_ADDR',
field_class=fields.BooleanField,
default=True,
label=_('TACACS+ client address sending enabled'),
help_text=_('Enable the client address sending by TACACS+ client.'),
category=_('TACACS+'),
category_slug='tacacsplus',
)
###############################################################################
# GOOGLE OAUTH2 AUTHENTICATION SETTINGS
###############################################################################
@@ -1344,21 +1267,3 @@ if settings.ALLOW_LOCAL_RESOURCE_MANAGEMENT:
category=_('Authentication'),
category_slug='authentication',
)
def tacacs_validate(serializer, attrs):
if not serializer.instance or not hasattr(serializer.instance, 'TACACSPLUS_HOST') or not hasattr(serializer.instance, 'TACACSPLUS_SECRET'):
return attrs
errors = []
host = serializer.instance.TACACSPLUS_HOST
if 'TACACSPLUS_HOST' in attrs:
host = attrs['TACACSPLUS_HOST']
secret = serializer.instance.TACACSPLUS_SECRET
if 'TACACSPLUS_SECRET' in attrs:
secret = attrs['TACACSPLUS_SECRET']
if host and not secret:
errors.append('TACACSPLUS_SECRET is required when TACACSPLUS_HOST is provided.')
if errors:
raise serializers.ValidationError(_('\n'.join(errors)))
return attrs
register_validate('tacacsplus', tacacs_validate)

View File

@@ -14,7 +14,6 @@ from rest_framework.fields import empty, Field, SkipField
# AWX
from awx.conf import fields
from awx.main.validators import validate_certificate
from awx.sso.validators import validate_tacacsplus_disallow_nonascii # noqa
def get_subclasses(cls):

View File

@@ -7,6 +7,7 @@ from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
# todo: this model to be removed as part of sso removal issue AAP-28380
class UserEnterpriseAuth(models.Model):
"""Enterprise Auth association model"""

View File

@@ -1,34 +0,0 @@
import pytest
from django.contrib.auth.models import User
from awx.sso.backends import TACACSPlusBackend
from awx.sso.models import UserEnterpriseAuth
@pytest.fixture
def tacacsplus_backend():
return TACACSPlusBackend()
@pytest.fixture
def existing_normal_user():
try:
user = User.objects.get(username="alice")
except User.DoesNotExist:
user = User(username="alice", password="password")
user.save()
return user
@pytest.fixture
def existing_tacacsplus_user():
try:
user = User.objects.get(username="foo")
except User.DoesNotExist:
user = User(username="foo")
user.set_unusable_password()
user.save()
enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+')
enterprise_auth.save()
return user

View File

@@ -324,7 +324,7 @@ class TestCommonFunctions:
if enable_enterprise:
from awx.sso.models import UserEnterpriseAuth
enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+')
enterprise_auth = UserEnterpriseAuth(user=user, provider='saml')
enterprise_auth.save()
assert get_external_account(user) == expected_results
@@ -336,7 +336,6 @@ class TestCommonFunctions:
('JUNK_SETTING', False),
('SOCIAL_AUTH_SAML_ENABLED_IDPS', True),
('RADIUS_SERVER', True),
('TACACSPLUS_HOST', True),
# Set some SOCIAL_SOCIAL_AUTH_OIDC_KEYAUTH_*_KEY settings
('SOCIAL_AUTH_AZUREAD_OAUTH2_KEY', True),
('SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY', True),

View File

@@ -1,37 +0,0 @@
# Python
import pytest
from unittest import mock
# AWX
from awx.sso.backends import _get_or_set_enterprise_user
@pytest.mark.django_db
def test_fetch_user_if_exist(existing_tacacsplus_user):
with mock.patch('awx.sso.backends.logger') as mocked_logger:
new_user = _get_or_set_enterprise_user("foo", "password", "tacacs+")
mocked_logger.debug.assert_not_called()
mocked_logger.warning.assert_not_called()
assert new_user == existing_tacacsplus_user
@pytest.mark.django_db
def test_create_user_if_not_exist(existing_tacacsplus_user):
with mock.patch('awx.sso.backends.logger') as mocked_logger:
new_user = _get_or_set_enterprise_user("bar", "password", "tacacs+")
mocked_logger.debug.assert_called_once_with(u'Created enterprise user bar via TACACS+ backend.')
assert new_user != existing_tacacsplus_user
@pytest.mark.django_db
def test_created_user_has_no_usable_password():
new_user = _get_or_set_enterprise_user("bar", "password", "tacacs+")
assert not new_user.has_usable_password()
@pytest.mark.django_db
def test_non_enterprise_user_does_not_get_pass(existing_normal_user):
with mock.patch('awx.sso.backends.logger') as mocked_logger:
new_user = _get_or_set_enterprise_user("alice", "password", "tacacs+")
mocked_logger.warning.assert_called_once_with(u'Enterprise user alice already defined in Tower.')
assert new_user is None

View File

@@ -1,116 +0,0 @@
from unittest import mock
import pytest
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(None, u"user", u"pass")
assert ret_user is None
def test_client_raises_exception(tacacsplus_backend):
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(
'tacacs_plus.TACACSClient', return_value=client
):
settings.TACACSPLUS_HOST = 'localhost'
settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii'
ret_user = tacacsplus_backend.authenticate(None, 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):
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('tacacs_plus.TACACSClient', return_value=client):
settings.TACACSPLUS_HOST = 'localhost'
settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii'
ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass")
assert ret_user is None
def test_client_return_valid_passes_auth(tacacsplus_backend):
auth = mock.MagicMock()
auth.valid = True
client = mock.MagicMock()
client.authenticate.return_value = auth
user = mock.MagicMock()
user.has_usable_password = mock.MagicMock(return_value=False)
with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch(
'awx.sso.backends._get_or_set_enterprise_user', return_value=user
):
settings.TACACSPLUS_HOST = 'localhost'
settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii'
ret_user = tacacsplus_backend.authenticate(None, u"user", u"pass")
assert ret_user == user
@pytest.mark.parametrize(
"client_ip_header,client_ip_header_value,expected_client_ip",
[('HTTP_X_FORWARDED_FOR', '12.34.56.78, 23.45.67.89', '12.34.56.78'), ('REMOTE_ADDR', '12.34.56.78', '12.34.56.78')],
)
def test_remote_addr_is_passed_to_client_if_available_and_setting_enabled(tacacsplus_backend, client_ip_header, client_ip_header_value, expected_client_ip):
auth = mock.MagicMock()
auth.valid = True
client = mock.MagicMock()
client.authenticate.return_value = auth
user = mock.MagicMock()
user.has_usable_password = mock.MagicMock(return_value=False)
request = mock.MagicMock()
request.META = {
client_ip_header: client_ip_header_value,
}
with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch(
'awx.sso.backends._get_or_set_enterprise_user', return_value=user
):
settings.TACACSPLUS_HOST = 'localhost'
settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii'
settings.TACACSPLUS_REM_ADDR = True
tacacsplus_backend.authenticate(request, u"user", u"pass")
client.authenticate.assert_called_once_with('user', 'pass', authen_type=1, rem_addr=expected_client_ip)
def test_remote_addr_is_completely_ignored_in_client_call_if_setting_is_disabled(tacacsplus_backend):
auth = mock.MagicMock()
auth.valid = True
client = mock.MagicMock()
client.authenticate.return_value = auth
user = mock.MagicMock()
user.has_usable_password = mock.MagicMock(return_value=False)
request = mock.MagicMock()
request.META = {}
with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch(
'awx.sso.backends._get_or_set_enterprise_user', return_value=user
):
settings.TACACSPLUS_HOST = 'localhost'
settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii'
settings.TACACSPLUS_REM_ADDR = False
tacacsplus_backend.authenticate(request, u"user", u"pass")
client.authenticate.assert_called_once_with('user', 'pass', authen_type=1)
def test_remote_addr_is_completely_ignored_in_client_call_if_unavailable_and_setting_enabled(tacacsplus_backend):
auth = mock.MagicMock()
auth.valid = True
client = mock.MagicMock()
client.authenticate.return_value = auth
user = mock.MagicMock()
user.has_usable_password = mock.MagicMock(return_value=False)
request = mock.MagicMock()
request.META = {}
with mock.patch('awx.sso.backends.django_settings') as settings, mock.patch('tacacs_plus.TACACSClient', return_value=client), mock.patch(
'awx.sso.backends._get_or_set_enterprise_user', return_value=user
):
settings.TACACSPLUS_HOST = 'localhost'
settings.TACACSPLUS_AUTH_PROTOCOL = 'ascii'
settings.TACACSPLUS_REM_ADDR = True
tacacsplus_backend.authenticate(request, u"user", u"pass")
client.authenticate.assert_called_once_with('user', 'pass', authen_type=1)

View File

@@ -2,13 +2,4 @@
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
__all__ = [
'validate_tacacsplus_disallow_nonascii',
]
def validate_tacacsplus_disallow_nonascii(value):
try:
value.encode('ascii')
except (UnicodeEncodeError, UnicodeDecodeError):
raise ValidationError(_('TACACS+ secret does not allow non-ascii characters'))
__all__ = []