mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 19:30:39 -03:30
Merge pull request #5920 from jangsutsr/5762_tacacsplus_auth_backend
A working TACACS+ backend and related CTiT settings
This commit is contained in:
commit
250ffca3ea
@ -286,6 +286,7 @@ REST_FRAMEWORK = {
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'awx.sso.backends.LDAPBackend',
|
||||
'awx.sso.backends.RADIUSBackend',
|
||||
'awx.sso.backends.TACACSPlusBackend',
|
||||
'social.backends.google.GoogleOAuth2',
|
||||
'social.backends.github.GithubOAuth2',
|
||||
'social.backends.github.GithubOrganizationOAuth2',
|
||||
@ -313,6 +314,14 @@ 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'
|
||||
|
||||
# Seconds before auth tokens expire.
|
||||
# Note: This setting may be overridden by database settings.
|
||||
AUTH_TOKEN_EXPIRATION = 1800
|
||||
|
||||
@ -22,6 +22,9 @@ from django_auth_ldap.backend import populate_user
|
||||
# radiusauth
|
||||
from radiusauth.backends import RADIUSBackend as BaseRADIUSBackend
|
||||
|
||||
# tacacs+ auth
|
||||
import tacacs_plus
|
||||
|
||||
# social
|
||||
from social.backends.saml import OID_USERID
|
||||
from social.backends.saml import SAMLAuth as BaseSAMLAuth
|
||||
@ -151,6 +154,60 @@ class RADIUSBackend(BaseRADIUSBackend):
|
||||
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):
|
||||
'''
|
||||
Custom Identity Provider to make attributes to what we expect.
|
||||
|
||||
@ -33,7 +33,7 @@ multiple organizations, otherwise the single default organization is used
|
||||
regardless of the key. Values are dictionaries defining the options for
|
||||
each organization's membership. For each organization it is possible to
|
||||
specify which users are automatically users of the organization and also
|
||||
which users can administer the organization.
|
||||
which users can administer the organization.
|
||||
|
||||
- admins: None, True/False, string or list of strings.
|
||||
If None, organization admins will not be updated.
|
||||
@ -51,7 +51,7 @@ which users can administer the organization.
|
||||
administrative list.
|
||||
- users: None, True/False, string or list of strings. Same rules apply as for
|
||||
admins.
|
||||
- remove_users: True/False. Defaults to True. Same rules as apply for
|
||||
- remove_users: True/False. Defaults to True. Same rules as apply for
|
||||
remove_admins.\
|
||||
''')
|
||||
|
||||
@ -520,6 +520,73 @@ register(
|
||||
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',
|
||||
feature_required='enterprise_auth',
|
||||
)
|
||||
|
||||
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',
|
||||
feature_required='enterprise_auth',
|
||||
)
|
||||
|
||||
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',
|
||||
feature_required='enterprise_auth',
|
||||
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',
|
||||
feature_required='enterprise_auth',
|
||||
)
|
||||
|
||||
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',
|
||||
feature_required='enterprise_auth',
|
||||
)
|
||||
|
||||
###############################################################################
|
||||
# GOOGLE OAUTH2 AUTHENTICATION SETTINGS
|
||||
###############################################################################
|
||||
|
||||
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',
|
||||
'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):
|
||||
@ -58,3 +59,10 @@ def validate_ldap_filter(value, with_user=False):
|
||||
|
||||
def validate_ldap_filter_with_user(value):
|
||||
validate_ldap_filter(value, with_user=True)
|
||||
|
||||
|
||||
def validate_tacacsplus_disallow_nonascii(value):
|
||||
try:
|
||||
value.encode('ascii')
|
||||
except (UnicodeEncodeError, UnicodeDecodeError):
|
||||
raise ValidationError(_('TACACS+ secret does not allow non-ascii characters'))
|
||||
|
||||
16
docs/auth/README.md
Normal file
16
docs/auth/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
This folder describes third-party authentications supported by Ansible Tower. These authentications can be configured and enabled inside Tower.
|
||||
|
||||
When a user wants to log into Tower, she can explicitly choose some of the supported authentications to log in instead of Tower's own authentication using username and password. Here is a list of such authentications:
|
||||
* Google OAuth2
|
||||
* Github OAuth2
|
||||
* Github Organization OAuth2
|
||||
* Github Team OAuth2
|
||||
* Microsoft Azure Active Directory (AD) OAuth2
|
||||
|
||||
On the other hand, the rest of authentication methods use the same types of login info as Tower(username and password), but authenticate using external auth systems rather than Tower's own database. If some of these methods are enabled, Tower will try authenticating using the enabled methods *before Tower's own authentication method*. In specific, it follows the order
|
||||
* LDAP
|
||||
* RADIUS
|
||||
* TACACS+
|
||||
* SAML
|
||||
|
||||
Tower will try authenticating against each enabled authentication method *in the specified order*, meaning if the same username and password is valid in multiple enabled auth methods (For example, both LDAP and TACACS+), Tower will only use the first positive match (In the above example, log a user in via LDAP and skip TACACS+).
|
||||
46
docs/auth/tacacsplus.md
Normal file
46
docs/auth/tacacsplus.md
Normal file
@ -0,0 +1,46 @@
|
||||
# TACACS+
|
||||
[Terminal Access Controller Access-Control System Plus (TACACS+)](https://en.wikipedia.org/wiki/TACACS) is a protocol developed by Cisco to handle remote authentication and related services for networked access control through a centralized server. In specific, TACACS+ provides authentication, authorization and accounting (AAA) services. Ansible Tower currently utilizes its authentication service.
|
||||
|
||||
TACACS+ is configured by Tower configuration and is available under `/api/<version #>/settings/tacacsplus/`. Here is a typical configuration with every configurable field included:
|
||||
```
|
||||
{
|
||||
"TACACSPLUS_HOST": "127.0.0.1",
|
||||
"TACACSPLUS_PORT": 49,
|
||||
"TACACSPLUS_SECRET": "secret",
|
||||
"TACACSPLUS_SESSION_TIMEOUT": 5,
|
||||
"TACACSPLUS_AUTH_PROTOCOL": "ascii"
|
||||
}
|
||||
```
|
||||
Below explains each field:
|
||||
|
||||
| Field Name | Field Value Type | Field Value Default | Description |
|
||||
|------------------------------|---------------------|---------------------|--------------------------------------------------------------------|
|
||||
| `TACACSPLUS_HOST` | String | '' (empty string) | Hostname of TACACS+ server. Empty string disables TACACS+ service. |
|
||||
| `TACACSPLUS_PORT` | Integer | 49 | Port number of TACACS+ server. |
|
||||
| `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` |
|
||||
|
||||
Under the hood, Tower uses [open-source TACACS+ python client](https://github.com/ansible/tacacs_plus) to communicate with the remote TACACS+ server. During authentication, Tower passes username and password to TACACS+ client, which packs up auth information and send to TACACS+ server. Based on what the server returns, Tower will invalidate login attempt if authentication fails. If authentication passes, Tower will create a user if she does not exist in database, and log the user in.
|
||||
|
||||
## Test environment setup
|
||||
|
||||
The suggested TACACS+ server for testing is [shrubbery TACACS+ daemon](http://www.shrubbery.net/tac_plus/). It is supposed to run on a centos machine. A verified candidate is centos 6.3 AMI in AWS EC2 Community AMIs (search for 'Centos 6.3 x86_64 HVM - Minimal with cloud-init aws-cfn-bootstrap and ec2-api-tools'). Note it is required to keep TCP port 49 open, since it's the default port used by TACACS+ daemon.
|
||||
|
||||
We provide [a playbook](https://github.com/jangsutsr/ansible-role-tacacs) to install a working TACACS+ server. Here is a typical test setup using the provided playbook.
|
||||
1. In AWS EC2, spawn the centos 6 machine.
|
||||
2. In Tower, create a test project using the stand-alone playbook inventory.
|
||||
3. In Tower, create a test inventory with the only host to be the spawned centos machine.
|
||||
4. In Tower, create and run a job template using the created project and inventory with parameters setup as below.
|
||||
|
||||

|
||||
|
||||
The playbook creates a user named 'tower' with ascii password default to 'login' and modifiable by extra_var `ascii_password` and pap password default to 'papme' and modifiable by extra_var `pap_password`. In order to configure TACACS+ server to meet custom test needs, we need to modify server-side file `/etc/tac_plus.conf` and `sudo service tac_plus restart` to restart the daemon. Details on how to modify config file can be found [here](http://manpages.ubuntu.com/manpages/xenial/man5/tac_plus.conf.5.html).
|
||||
|
||||
## Acceptance criteria
|
||||
* All specified Tower configuration fields should be shown and configurable as documented.
|
||||
* User defined by TACACS+ server should be able to log in Tower.
|
||||
* User not defined by TACACS+ server should not be able to log in Tower via TACACS+.
|
||||
* A user existing in TACACS+ server but not in Tower should be created after the first success log in.
|
||||
* TACACS+ backend should stop authentication attempt after configured timeout and should not block the authentication pipeline in any case.
|
||||
* If exceptions occur on TACACS+ server side, the exception details should be logged in Tower, and Tower should not authenticate that user via TACACS+.
|
||||
BIN
docs/img/auth_tacacsplus_1.png
Normal file
BIN
docs/img/auth_tacacsplus_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Loading…
x
Reference in New Issue
Block a user