From 844666df4ca4b406568d5d58180d7a4719872c36 Mon Sep 17 00:00:00 2001 From: John Westcott IV <32551173+john-westcott-iv@users.noreply.github.com> Date: Fri, 2 Jun 2023 10:03:56 -0400 Subject: [PATCH] Send real client remote address in TACACS+ authentication packet (#14077) Co-authored-by: ekougs --- awx/settings/defaults.py | 1 + awx/sso/backends.py | 21 +++++- awx/sso/conf.py | 10 +++ awx/sso/tests/unit/test_tacacsplus.py | 67 +++++++++++++++++++ .../src/screens/Setting/TACACS/TACACS.test.js | 1 + .../TACACS/TACACSDetail/TACACSDetail.test.js | 1 + .../Setting/TACACS/TACACSEdit/TACACSEdit.js | 5 ++ .../TACACS/TACACSEdit/TACACSEdit.test.js | 2 + .../shared/data.allSettingOptions.json | 18 +++++ .../Setting/shared/data.allSettings.json | 1 + docs/auth/tacacsplus.md | 4 +- 11 files changed, 128 insertions(+), 3 deletions(-) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 8a73479e96..795cb44fe3 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -419,6 +419,7 @@ 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. diff --git a/awx/sso/backends.py b/awx/sso/backends.py index 8b59737df8..82f538771e 100644 --- a/awx/sso/backends.py +++ b/awx/sso/backends.py @@ -224,12 +224,18 @@ class TACACSPlusBackend(object): return None try: # Upstream TACACS+ client does not accept non-string, so convert if needed. - auth = tacacs_plus.TACACSClient( + tacacs_client = tacacs_plus.TACACSClient( django_settings.TACACSPLUS_HOST, django_settings.TACACSPLUS_PORT, django_settings.TACACSPLUS_SECRET, timeout=django_settings.TACACSPLUS_SESSION_TIMEOUT, - ).authenticate(username, password, authen_type=tacacs_plus.TAC_PLUS_AUTHEN_TYPES[django_settings.TACACSPLUS_AUTH_PROTOCOL]) + ) + 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 @@ -244,6 +250,17 @@ class TACACSPlusBackend(object): 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): """ diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 91509e0bea..3012930965 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -554,6 +554,16 @@ register( 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 ############################################################################### diff --git a/awx/sso/tests/unit/test_tacacsplus.py b/awx/sso/tests/unit/test_tacacsplus.py index 60ed0c4799..49315a9643 100644 --- a/awx/sso/tests/unit/test_tacacsplus.py +++ b/awx/sso/tests/unit/test_tacacsplus.py @@ -1,4 +1,5 @@ from unittest import mock +import pytest def test_empty_host_fails_auth(tacacsplus_backend): @@ -47,3 +48,69 @@ def test_client_return_valid_passes_auth(tacacsplus_backend): 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) diff --git a/awx/ui/src/screens/Setting/TACACS/TACACS.test.js b/awx/ui/src/screens/Setting/TACACS/TACACS.test.js index 4a44ac73a7..0a4c636163 100644 --- a/awx/ui/src/screens/Setting/TACACS/TACACS.test.js +++ b/awx/ui/src/screens/Setting/TACACS/TACACS.test.js @@ -15,6 +15,7 @@ SettingsAPI.readCategory.mockResolvedValue({ TACACSPLUS_SECRET: '$encrypted$', TACACSPLUS_SESSION_TIMEOUT: 5, TACACSPLUS_AUTH_PROTOCOL: 'ascii', + TACACSPLUS_REM_ADDR: false, }, }); diff --git a/awx/ui/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.test.js b/awx/ui/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.test.js index 3244c1110d..b9bf59f557 100644 --- a/awx/ui/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.test.js +++ b/awx/ui/src/screens/Setting/TACACS/TACACSDetail/TACACSDetail.test.js @@ -23,6 +23,7 @@ describe('', () => { TACACSPLUS_SECRET: '$encrypted$', TACACSPLUS_SESSION_TIMEOUT: 5, TACACSPLUS_AUTH_PROTOCOL: 'ascii', + TACACSPLUS_REM_ADDR: false, }, }); }); diff --git a/awx/ui/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.js b/awx/ui/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.js index d1894b53c8..d9ab437774 100644 --- a/awx/ui/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.js +++ b/awx/ui/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.js @@ -12,6 +12,7 @@ import useModal from 'hooks/useModal'; import useRequest from 'hooks/useRequest'; import { SettingsAPI } from 'api'; import { + BooleanField, ChoiceField, EncryptedField, InputField, @@ -116,6 +117,10 @@ function TACACSEdit() { name="TACACSPLUS_AUTH_PROTOCOL" config={tacacs.TACACSPLUS_AUTH_PROTOCOL} /> + {submitError && } {revertError && } diff --git a/awx/ui/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.js b/awx/ui/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.js index 4e41ddd58b..c717cb8898 100644 --- a/awx/ui/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.js +++ b/awx/ui/src/screens/Setting/TACACS/TACACSEdit/TACACSEdit.test.js @@ -26,6 +26,7 @@ describe('', () => { TACACSPLUS_SECRET: '$encrypted$', TACACSPLUS_SESSION_TIMEOUT: 123, TACACSPLUS_AUTH_PROTOCOL: 'ascii', + TACACSPLUS_REM_ADDR: false, }, }); }); @@ -112,6 +113,7 @@ describe('', () => { TACACSPLUS_SECRET: '', TACACSPLUS_SESSION_TIMEOUT: 123, TACACSPLUS_AUTH_PROTOCOL: 'ascii', + TACACSPLUS_REM_ADDR: false, }); }); diff --git a/awx/ui/src/screens/Setting/shared/data.allSettingOptions.json b/awx/ui/src/screens/Setting/shared/data.allSettingOptions.json index 124169d3d1..cc6352befc 100644 --- a/awx/ui/src/screens/Setting/shared/data.allSettingOptions.json +++ b/awx/ui/src/screens/Setting/shared/data.allSettingOptions.json @@ -2708,6 +2708,15 @@ ["pap", "pap"] ] }, + "TACACSPLUS_REM_ADDR": { + "type": "boolean", + "required": false, + "label": "TACACS+ client address sending enabled", + "help_text": "Enable the client address sending by TACACS+ client.", + "category": "TACACS+", + "category_slug": "tacacsplus", + "default": false + }, "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": { "type": "string", "required": false, @@ -5936,6 +5945,15 @@ ["pap", "pap"] ] }, + "TACACSPLUS_REM_ADDR": { + "type": "boolean", + "required": false, + "label": "TACACS+ client address sending enabled", + "help_text": "Enable the client address sending by TACACS+ client.", + "category": "TACACS+", + "category_slug": "tacacsplus", + "defined_in_file": false + }, "SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL": { "type": "string", "label": "Google OAuth2 Callback URL", diff --git a/awx/ui/src/screens/Setting/shared/data.allSettings.json b/awx/ui/src/screens/Setting/shared/data.allSettings.json index be6d819201..1d9f2f6e2b 100644 --- a/awx/ui/src/screens/Setting/shared/data.allSettings.json +++ b/awx/ui/src/screens/Setting/shared/data.allSettings.json @@ -251,6 +251,7 @@ "TACACSPLUS_SECRET": "", "TACACSPLUS_SESSION_TIMEOUT": 5, "TACACSPLUS_AUTH_PROTOCOL": "ascii", + "TACACSPLUS_REM_ADDR": false, "SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL": "https://localhost:3000/sso/complete/google-oauth2/", "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "", "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "", diff --git a/docs/auth/tacacsplus.md b/docs/auth/tacacsplus.md index cda18cb3e6..f895ed4aeb 100644 --- a/docs/auth/tacacsplus.md +++ b/docs/auth/tacacsplus.md @@ -8,7 +8,8 @@ TACACS+ is configured by settings configuration and is available under `/api/v2/ "TACACSPLUS_PORT": 49, "TACACSPLUS_SECRET": "secret", "TACACSPLUS_SESSION_TIMEOUT": 5, - "TACACSPLUS_AUTH_PROTOCOL": "ascii" + "TACACSPLUS_AUTH_PROTOCOL": "ascii", + "TACACSPLUS_REM_ADDR": "false" } ``` Each field is explained below: @@ -20,6 +21,7 @@ Each field is explained below: | `TACACSPLUS_SECRET` | String | '' (empty string) | Shared secret for authenticating to TACACS+ server. | | `TACACSPLUS_SESSION_TIMEOUT` | Integer | 5 | TACACS+ session timeout value in seconds. | | `TACACSPLUS_AUTH_PROTOCOL` | String with choices | 'ascii' | The authentication protocol used by TACACS+ client (choices are `ascii` and `pap`). | +| `TACACSPLUS_REM_ADDR` | Boolean | false | Enable the client address sending by TACACS+ client. | Under the hood, AWX uses [open-source TACACS+ python client](https://github.com/ansible/tacacs_plus) to communicate with the remote TACACS+ server. During authentication, AWX passes username and password to TACACS+ client, which packs up auth information and sends it to the TACACS+ server. Based on what the server returns, AWX will invalidate login attempt if authentication fails. If authentication passes, AWX will create a user if she does not exist in database, and log the user in.