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.