Introduce sso UserEnterpriseAuth model.

This commit is contained in:
Aaron Tan 2017-05-18 15:38:32 -04:00
parent 8cf8e6c0c0
commit d314f83416
10 changed files with 139 additions and 70 deletions

View File

@ -786,7 +786,8 @@ class UserSerializer(BaseSerializer):
getattr(settings, 'SOCIAL_AUTH_GITHUB_TEAM_KEY', None) or
getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all():
new_password = None
if obj.pk and getattr(settings, 'RADIUS_SERVER', '') and not obj.has_usable_password():
if (getattr(settings, 'RADIUS_SERVER', None) or
getattr(settings, 'TACACSPLUS_HOST', None)) and obj.enterprise_auth.all():
new_password = None
if new_password:
obj.set_password(new_password)
@ -809,8 +810,9 @@ class UserSerializer(BaseSerializer):
getattr(settings, 'SOCIAL_AUTH_GITHUB_TEAM_KEY', None) or
getattr(settings, 'SOCIAL_AUTH_SAML_ENABLED_IDPS', None)) and obj.social_auth.all():
account_type = "social"
if obj.pk and getattr(settings, 'RADIUS_SERVER', '') and not obj.has_usable_password():
account_type = "radius"
if (getattr(settings, 'RADIUS_SERVER', None) or
getattr(settings, 'TACACSPLUS_HOST', None)) and obj.enterprise_auth.all():
account_type = "enterprise"
return account_type
def create(self, validated_data):
@ -3437,7 +3439,7 @@ class InstanceGroupSerializer(BaseSerializer):
def get_instances(self, obj):
return obj.instances.count()
class ActivityStreamSerializer(BaseSerializer):
changes = serializers.SerializerMethodField()

View File

@ -31,6 +31,7 @@ from social.backends.saml import SAMLIdentityProvider as BaseSAMLIdentityProvide
# Ansible Tower
from awx.conf.license import feature_enabled
from awx.sso.models import UserEnterpriseAuth
logger = logging.getLogger('awx.sso.backends')
@ -119,6 +120,27 @@ class LDAPBackend(BaseLDAPBackend):
return set()
def _get_or_set_enterprise_user(username, password, provider):
created = False
try:
user = User.objects.all().prefetch_related('enterprise_auth').get(username=username)
except User.DoesNotExist:
user = User(username=username)
user.set_unusable_password()
user.save()
enterprise_auth = UserEnterpriseAuth(user=user, provider=provider)
enterprise_auth.save()
logger.debug("Created enterprise user %s via %s backend." %
(username, enterprise_auth.get_provider_display()))
created = True
# NOTE: remove has_usable_password logic in a future release.
if created or\
not user.has_usable_password() or\
(provider,) in user.enterprise_auth.all().values_list('provider') and not user.has_usable_password():
return user
logger.warn("Enterprise user %s already defined in Tower." % username)
class RADIUSBackend(BaseRADIUSBackend):
'''
Custom Radius backend to verify license status
@ -143,31 +165,13 @@ class RADIUSBackend(BaseRADIUSBackend):
return user
def get_django_user(self, username, password=None):
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
logger.debug("Created RADIUS user %s" % (username,))
user = User(username=username)
user.set_unusable_password()
user.save()
return user
return _get_or_set_enterprise_user(username, password, 'radius')
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,))
user.set_unusable_password()
user.save()
return user
def authenticate(self, username, password):
if not django_settings.TACACSPLUS_HOST:
@ -190,10 +194,7 @@ class TACACSPlusBackend(object):
logger.exception("TACACS+ Authentication Error: %s" % (e.message,))
return None
if auth.valid:
user = self._get_or_set_user(username, password)
if not user.has_usable_password():
return user
return None
return _get_or_set_enterprise_user(username, password, 'tacacs+')
def get_user(self, user_id):
if not django_settings.TACACSPLUS_HOST:

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserEnterpriseAuth',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('provider', models.CharField(max_length=32, choices=[(b'radius', 'RADIUS'), (b'tacacs+', 'TACACS+')])),
('user', models.ForeignKey(related_name='enterprise_auth', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AlterUniqueTogether(
name='userenterpriseauth',
unique_together=set([('user', 'provider')]),
),
]

View File

View File

@ -1,2 +1,26 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Django
from django.db import models
from django.contrib.auth.models import User
from django.utils.translation import ugettext_lazy as _
class UserEnterpriseAuth(models.Model):
"""Tower Enterprise Auth association model"""
PROVIDER_CHOICES = (
('radius', _('RADIUS')),
('tacacs+', _('TACACS+')),
)
class Meta:
unique_together = ('user', 'provider')
user = models.ForeignKey(
User, related_name='enterprise_auth', on_delete=models.CASCADE
)
provider = models.CharField(
max_length=32, choices=PROVIDER_CHOICES
)

View File

@ -3,6 +3,7 @@ import pytest
from django.contrib.auth.models import User
from awx.sso.backends import TACACSPlusBackend
from awx.sso.models import UserEnterpriseAuth
@pytest.fixture
@ -10,6 +11,16 @@ 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:
@ -17,6 +28,8 @@ def existing_tacacsplus_user():
except User.DoesNotExist:
user = User(username="foo")
user.save()
enterprise_auth = UserEnterpriseAuth(user=user, provider='tacacs+')
enterprise_auth.save()
return user

View File

@ -0,0 +1,38 @@
# Python
import pytest
import mock
# Tower
from awx.sso.backends import _get_or_set_enterprise_user
@pytest.mark.django_db
def test_fetch_user_if_exist(existing_tacacsplus_user):
new_user = _get_or_set_enterprise_user("foo", "password", "tacacs+")
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.warn.assert_called_once_with(
u'Enterprise user alice already defined in Tower.'
)
assert new_user is None

View File

@ -1,24 +0,0 @@
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()

View File

@ -50,23 +50,6 @@ def test_client_return_invalid_fails_auth(tacacsplus_backend, feature_enabled):
assert ret_user is None
def test_user_with_password_fails_auth(tacacsplus_backend, feature_enabled):
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=True)
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 is None
def test_client_return_valid_passes_auth(tacacsplus_backend, feature_enabled):
auth = mock.MagicMock()
auth.valid = True
@ -77,7 +60,7 @@ def test_client_return_valid_passes_auth(tacacsplus_backend, feature_enabled):
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):
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(u"user", u"pass")

View File

@ -16,4 +16,9 @@ On the other hand, the rest of authentication methods use the same types of logi
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+).
## Notes:
* TACACS+/RADIUS users and normal Tower users are strictly separated. For example, suppose there is a TACACS+ user with username 'Alice' which is known to TACACS+ backend but not Tower. If a user record with the same username 'Alice' is created in Tower before any log in attempt, Tower will always use its own authentication backend to authenticate Alice, even if TACACS+ backend is also available. On the other hand, if a successful log in attempt is conducted before any explicit user creation in Tower, a TACACS+ user 'Alice' is automatically created and Tower will always use TACACS+ backend for authenticating 'Alice'.
* RADIUS users and TACACS+ users are categorized as 'Enterprise' users. The following rules apply to Enterprise users:
* Enterprise users can only be created via the first successful login attempt from remote authentication backend.
* Enterprise users cannot be created/authenticated if non-enterprise users with the same name has already been created in Tower.
* Tower passwords of Enterprise users should always be empty and cannot be set by any user if there are enterprise backends enabled.
* If enterprise backends (RADIUS and TACACS+ for now) are disabled, an Enterprise user can be converted to a normal Tower user by setting password field. But this operation is irreversible (The converted Tower user can no longer be treated as Enterprise user)