Send real client remote address in TACACS+ authentication packet (#14077)

Co-authored-by: ekougs <ekougs@gmail.com>
This commit is contained in:
John Westcott IV 2023-06-02 10:03:56 -04:00 committed by GitHub
parent 0ae720244c
commit 844666df4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 128 additions and 3 deletions

View File

@ -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.

View File

@ -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):
"""

View File

@ -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
###############################################################################

View File

@ -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)

View File

@ -15,6 +15,7 @@ SettingsAPI.readCategory.mockResolvedValue({
TACACSPLUS_SECRET: '$encrypted$',
TACACSPLUS_SESSION_TIMEOUT: 5,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
TACACSPLUS_REM_ADDR: false,
},
});

View File

@ -23,6 +23,7 @@ describe('<TACACSDetail />', () => {
TACACSPLUS_SECRET: '$encrypted$',
TACACSPLUS_SESSION_TIMEOUT: 5,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
TACACSPLUS_REM_ADDR: false,
},
});
});

View File

@ -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}
/>
<BooleanField
name="TACACSPLUS_REM_ADDR"
config={tacacs.TACACSPLUS_REM_ADDR}
/>
{submitError && <FormSubmitError error={submitError} />}
{revertError && <FormSubmitError error={revertError} />}
</FormColumnLayout>

View File

@ -26,6 +26,7 @@ describe('<TACACSEdit />', () => {
TACACSPLUS_SECRET: '$encrypted$',
TACACSPLUS_SESSION_TIMEOUT: 123,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
TACACSPLUS_REM_ADDR: false,
},
});
});
@ -112,6 +113,7 @@ describe('<TACACSEdit />', () => {
TACACSPLUS_SECRET: '',
TACACSPLUS_SESSION_TIMEOUT: 123,
TACACSPLUS_AUTH_PROTOCOL: 'ascii',
TACACSPLUS_REM_ADDR: false,
});
});

View File

@ -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",

View File

@ -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": "",

View File

@ -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.