diff --git a/awx/api/conf.py b/awx/api/conf.py index 0a2ac6a89f..00c712a064 100644 --- a/awx/api/conf.py +++ b/awx/api/conf.py @@ -1,8 +1,12 @@ # Django +from django.conf import settings from django.utils.translation import ugettext_lazy as _ +# Django REST Framework +from rest_framework import serializers + # AWX -from awx.conf import fields, register +from awx.conf import fields, register, register_validate from awx.api.fields import OAuth2ProviderField from oauth2_provider.settings import oauth2_settings @@ -27,6 +31,17 @@ register( category=_('Authentication'), category_slug='authentication', ) +register( + 'DISABLE_LOCAL_AUTH', + field_class=fields.BooleanField, + label=_('Disable the built-in authentication system'), + help_text=_( + "Controls whether users are prevented from using the built-in authentication system. " + "You probably want to do this if you are using an LDAP or SAML integration." + ), + category=_('Authentication'), + category_slug='authentication', +) register( 'AUTH_BASIC_ENABLED', field_class=fields.BooleanField, @@ -81,3 +96,23 @@ register( category=_('Authentication'), category_slug='authentication', ) + + +def authentication_validate(serializer, attrs): + remote_auth_settings = [ + 'AUTH_LDAP_SERVER_URI', + 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY', + 'SOCIAL_AUTH_GITHUB_KEY', + 'SOCIAL_AUTH_GITHUB_ORG_KEY', + 'SOCIAL_AUTH_GITHUB_TEAM_KEY', + 'SOCIAL_AUTH_SAML_ENABLED_IDPS', + 'RADIUS_SERVER', + 'TACACSPLUS_HOST', + ] + if attrs.get('DISABLE_LOCAL_AUTH', False): + if not any(getattr(settings, s, None) for s in remote_auth_settings): + raise serializers.ValidationError(_("There are no remote authentication systems configured.")) + return attrs + + +register_validate('authentication', authentication_validate) diff --git a/awx/conf/signals.py b/awx/conf/signals.py index 00e486734b..843900d9e6 100644 --- a/awx/conf/signals.py +++ b/awx/conf/signals.py @@ -3,9 +3,9 @@ import logging # Django from django.conf import settings +from django.core.cache import cache from django.core.signals import setting_changed from django.db.models.signals import post_save, pre_delete, post_delete -from django.core.cache import cache from django.dispatch import receiver # AWX @@ -25,7 +25,7 @@ def handle_setting_change(key, for_delete=False): # Note: Doesn't handle multiple levels of dependencies! setting_keys.append(dependent_key) # NOTE: This block is probably duplicated. - cache_keys = set([Setting.get_cache_key(k) for k in setting_keys]) + cache_keys = {Setting.get_cache_key(k) for k in setting_keys} cache.delete_many(cache_keys) # Send setting_changed signal with new value for each setting. @@ -58,3 +58,18 @@ def on_post_delete_setting(sender, **kwargs): key = getattr(instance, '_saved_key_', None) if key: handle_setting_change(key, True) + + +@receiver(setting_changed) +def disable_local_auth(**kwargs): + if (kwargs['setting'], kwargs['value']) == ('DISABLE_LOCAL_AUTH', True): + from django.contrib.auth.models import User + from oauth2_provider.models import RefreshToken + from awx.main.models.oauth import OAuth2AccessToken + from awx.main.management.commands.revoke_oauth2_tokens import revoke_tokens + + logger.warning("Triggering token invalidation for local users.") + + qs = User.objects.filter(profile__ldap_dn='', enterprise_auth__isnull=True, social_auth__isnull=True) + revoke_tokens(RefreshToken.objects.filter(revoked=None, user__in=qs)) + revoke_tokens(OAuth2AccessToken.objects.filter(user__in=qs)) diff --git a/awx/main/backends.py b/awx/main/backends.py new file mode 100644 index 0000000000..722b94805f --- /dev/null +++ b/awx/main/backends.py @@ -0,0 +1,14 @@ +import logging + +from django.conf import settings +from django.contrib.auth.backends import ModelBackend + +logger = logging.getLogger('awx.main.backends') + + +class AWXModelBackend(ModelBackend): + def authenticate(self, request, **kwargs): + if settings.DISABLE_LOCAL_AUTH: + logger.warning(f"User '{kwargs['username']}' attempted login through the disabled local authentication system.") + return + return super().authenticate(request, **kwargs) diff --git a/awx/main/conf.py b/awx/main/conf.py index e463291556..09360fc54f 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -36,7 +36,7 @@ register( 'ORG_ADMINS_CAN_SEE_ALL_USERS', field_class=fields.BooleanField, label=_('All Users Visible to Organization Admins'), - help_text=_('Controls whether any Organization Admin can view all users and teams, ' 'even those not associated with their Organization.'), + help_text=_('Controls whether any Organization Admin can view all users and teams, even those not associated with their Organization.'), category=_('System'), category_slug='system', ) @@ -59,7 +59,7 @@ register( schemes=('http', 'https'), allow_plain_hostname=True, # Allow hostname only without TLD. label=_('Base URL of the service'), - help_text=_('This setting is used by services like notifications to render ' 'a valid url to the service.'), + help_text=_('This setting is used by services like notifications to render a valid url to the service.'), category=_('System'), category_slug='system', ) @@ -94,13 +94,12 @@ register( category_slug='system', ) - register( 'LICENSE', field_class=fields.DictField, default=lambda: {}, label=_('License'), - help_text=_('The license controls which features and functionality are ' 'enabled. Use /api/v2/config/ to update or change ' 'the license.'), + help_text=_('The license controls which features and functionality are enabled. Use /api/v2/config/ to update or change the license.'), category=_('System'), category_slug='system', ) @@ -194,7 +193,7 @@ register( 'CUSTOM_VENV_PATHS', field_class=fields.StringListPathField, label=_('Custom virtual environment paths'), - help_text=_('Paths where Tower will look for custom virtual environments ' '(in addition to /var/lib/awx/venv/). Enter one path per line.'), + help_text=_('Paths where Tower will look for custom virtual environments (in addition to /var/lib/awx/venv/). Enter one path per line.'), category=_('System'), category_slug='system', default=[], @@ -318,7 +317,7 @@ register( field_class=fields.BooleanField, default=False, label=_('Ignore Ansible Galaxy SSL Certificate Verification'), - help_text=_('If set to true, certificate validation will not be done when ' 'installing content from any Galaxy server.'), + help_text=_('If set to true, certificate validation will not be done when installing content from any Galaxy server.'), category=_('Jobs'), category_slug='jobs', ) @@ -433,7 +432,7 @@ register( allow_null=False, default=200, label=_('Maximum number of forks per job'), - help_text=_('Saving a Job Template with more than this number of forks will result in an error. ' 'When set to 0, no limit is applied.'), + help_text=_('Saving a Job Template with more than this number of forks will result in an error. When set to 0, no limit is applied.'), category=_('Jobs'), category_slug='jobs', ) @@ -454,7 +453,7 @@ register( allow_null=True, default=None, label=_('Logging Aggregator Port'), - help_text=_('Port on Logging Aggregator to send logs to (if required and not' ' provided in Logging Aggregator).'), + help_text=_('Port on Logging Aggregator to send logs to (if required and not provided in Logging Aggregator).'), category=_('Logging'), category_slug='logging', required=False, @@ -561,7 +560,7 @@ register( field_class=fields.IntegerField, default=5, label=_('TCP Connection Timeout'), - help_text=_('Number of seconds for a TCP connection to external log ' 'aggregator to timeout. Applies to HTTPS and TCP log ' 'aggregator protocols.'), + help_text=_('Number of seconds for a TCP connection to external log aggregator to timeout. Applies to HTTPS and TCP log aggregator protocols.'), category=_('Logging'), category_slug='logging', unit=_('seconds'), @@ -627,7 +626,7 @@ register( field_class=fields.BooleanField, default=False, label=_('Enable rsyslogd debugging'), - help_text=_('Enabled high verbosity debugging for rsyslogd. ' 'Useful for debugging connection issues for external log aggregation.'), + help_text=_('Enabled high verbosity debugging for rsyslogd. Useful for debugging connection issues for external log aggregation.'), category=_('Logging'), category_slug='logging', ) diff --git a/awx/main/management/commands/expire_sessions.py b/awx/main/management/commands/expire_sessions.py index 65053e3e50..2f7a17c450 100644 --- a/awx/main/management/commands/expire_sessions.py +++ b/awx/main/management/commands/expire_sessions.py @@ -31,6 +31,7 @@ class Command(BaseCommand): for session in sessions: user_id = session.get_decoded().get('_auth_user_id') if (user is None) or (user_id and user.id == int(user_id)): + # The Session model instance doesn't have .flush(), we need a SessionStore instance. session = import_module(settings.SESSION_ENGINE).SessionStore(session.session_key) # Log out the session, but without the need for a request object. session.flush() diff --git a/awx/main/middleware.py b/awx/main/middleware.py index 05c4564ffa..05e03777fb 100644 --- a/awx/main/middleware.py +++ b/awx/main/middleware.py @@ -7,6 +7,7 @@ import time import urllib.parse from django.conf import settings +from django.contrib.auth import logout from django.contrib.auth.models import User from django.db.migrations.executor import MigrationExecutor from django.db import connection @@ -71,6 +72,21 @@ class SessionTimeoutMiddleware(MiddlewareMixin): return response +class DisableLocalAuthMiddleware(MiddlewareMixin): + """ + Respects the presence of the DISABLE_LOCAL_AUTH setting and forces + local-only users to logout when they make a request. + """ + + def process_request(self, request): + if settings.DISABLE_LOCAL_AUTH: + user = request.user + if not user.pk: + return + if not (user.profile.ldap_dn or user.social_auth.exists() or user.enterprise_auth.exists()): + logout(request) + + def _customize_graph(): from awx.main.models import Instance, Schedule, UnifiedJobTemplate diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index ea3427ee8c..3f1f385fae 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -364,7 +364,7 @@ AUTHENTICATION_BACKENDS = ( 'social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2', 'social_core.backends.azuread.AzureADOAuth2', 'awx.sso.backends.SAMLAuth', - 'django.contrib.auth.backends.ModelBackend', + 'awx.main.backends.AWXModelBackend', ) @@ -716,6 +716,7 @@ CALLBACK_QUEUE = "callback_tasks" # Note: This setting may be overridden by database settings. ORG_ADMINS_CAN_SEE_ALL_USERS = True MANAGE_ORGANIZATION_AUTH = True +DISABLE_LOCAL_AUTH = False # Note: This setting may be overridden by database settings. TOWER_URL_BASE = "https://towerhost" @@ -913,6 +914,7 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'awx.main.middleware.DisableLocalAuthMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'awx.sso.middleware.SocialAuthMiddleware', 'crum.CurrentRequestUserMiddleware', diff --git a/awx/sso/fields.py b/awx/sso/fields.py index c2ad629d6f..deef330842 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -196,6 +196,7 @@ class AuthenticationBackendsField(fields.StringListField): ], ), ('django.contrib.auth.backends.ModelBackend', []), + ('awx.main.backends.AWXModelBackend', []), ] ) diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx index 75c8c0fce8..de5080eec4 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.jsx @@ -48,6 +48,7 @@ function MiscSystemDetail() { 'INSIGHTS_TRACKING_STATE', 'LOGIN_REDIRECT_OVERRIDE', 'MANAGE_ORGANIZATION_AUTH', + 'DISABLE_LOCAL_AUTH', 'OAUTH2_PROVIDER', 'ORG_ADMINS_CAN_SEE_ALL_USERS', 'REDHAT_PASSWORD', diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.test.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.test.jsx index e02f0680a0..2f9dd48dff 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.test.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemDetail/MiscSystemDetail.test.jsx @@ -30,6 +30,7 @@ describe('', () => { INSIGHTS_TRACKING_STATE: false, LOGIN_REDIRECT_OVERRIDE: 'https://redirect.com', MANAGE_ORGANIZATION_AUTH: true, + DISABLE_LOCAL_AUTH: false, OAUTH2_PROVIDER: { ACCESS_TOKEN_EXPIRE_SECONDS: 1, AUTHORIZATION_CODE_EXPIRE_SECONDS: 2, diff --git a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx index 8206fb0a3c..f72b12b794 100644 --- a/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx +++ b/awx/ui_next/src/screens/Setting/MiscSystem/MiscSystemEdit/MiscSystemEdit.jsx @@ -48,6 +48,7 @@ function MiscSystemEdit() { 'INSIGHTS_TRACKING_STATE', 'LOGIN_REDIRECT_OVERRIDE', 'MANAGE_ORGANIZATION_AUTH', + 'DISABLE_LOCAL_AUTH', 'OAUTH2_PROVIDER', 'ORG_ADMINS_CAN_SEE_ALL_USERS', 'REDHAT_PASSWORD', @@ -261,6 +262,12 @@ function MiscSystemEdit() { name="MANAGE_ORGANIZATION_AUTH" config={system.MANAGE_ORGANIZATION_AUTH} /> + ); }; -const BooleanField = ({ ariaLabel = '', name, config, disabled = false }) => { +const BooleanField = ({ + ariaLabel = '', + name, + config, + disabled = false, + needsConfirmationModal, + modalTitle, +}) => { const [field, meta, helpers] = useField(name); + const [isModalOpen, setIsModalOpen] = useState(false); + + if (isModalOpen) { + return ( + { + helpers.setValue(false); + }} + actions={[ + , + , + ]} + >{t`Are you sure you want to disable local authentication? Doing so could impact users' ability to log in and the system administrator's ability to reverse this change.`} + ); + } return config ? ( { isDisabled={disabled} label={t`On`} labelOff={t`Off`} - onChange={() => helpers.setValue(!field.value)} + onChange={() => + needsConfirmationModal + ? setIsModalOpen(true) + : helpers.setValue(!field.value) + } aria-label={ariaLabel || config.label} /> diff --git a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx index 3ac561e3d2..35c494fe83 100644 --- a/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx +++ b/awx/ui_next/src/screens/Setting/shared/SharedFields.test.jsx @@ -265,4 +265,96 @@ describe('Setting form fields', () => { wrapper.update(); expect(wrapper.find('input#mock_file-filename').prop('value')).toEqual(''); }); + test('should render confirmation modal when toggle on for disable local auth', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('Switch')).toHaveLength(1); + expect(wrapper.find('Switch').prop('isChecked')).toBe(false); + expect(wrapper.find('Switch').prop('isDisabled')).toBe(false); + await act(async () => { + wrapper.find('Switch').invoke('onChange')(); + }); + wrapper.update(); + + expect(wrapper.find('AlertModal')).toHaveLength(1); + await act(async () => + wrapper + .find('Button[ouiaId="confirm-misc-settings-modal"]') + .prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('AlertModal')).toHaveLength(0); + expect(wrapper.find('Switch').prop('isChecked')).toBe(true); + }); + + test('shold not toggle disable local auth', async () => { + const wrapper = mountWithContexts( + + {() => ( + + )} + + ); + expect(wrapper.find('Switch')).toHaveLength(1); + expect(wrapper.find('Switch').prop('isChecked')).toBe(false); + expect(wrapper.find('Switch').prop('isDisabled')).toBe(false); + await act(async () => { + wrapper.find('Switch').invoke('onChange')(); + }); + wrapper.update(); + + expect(wrapper.find('AlertModal')).toHaveLength(1); + await act(async () => + wrapper + .find('Button[ouiaId="cancel-misc-settings-modal"]') + .prop('onClick')() + ); + wrapper.update(); + + expect(wrapper.find('AlertModal')).toHaveLength(0); + expect(wrapper.find('Switch').prop('isChecked')).toBe(false); + }); }); diff --git a/awx/ui_next/src/screens/Setting/shared/data.allSettingOptions.json b/awx/ui_next/src/screens/Setting/shared/data.allSettingOptions.json index 7a729474d5..8a0049b899 100644 --- a/awx/ui_next/src/screens/Setting/shared/data.allSettingOptions.json +++ b/awx/ui_next/src/screens/Setting/shared/data.allSettingOptions.json @@ -34,6 +34,14 @@ "category_slug": "system", "defined_in_file": false }, + "DISABLE_LOCAL_AUTH": { + "type": "boolean", + "label": "Disable the built-in authentication system", + "help_text": "Controls whether users are prevented from using the built-in authentication system. You probably want to do this if you are using an LDAP or SAML integration.", + "category": "Authentication", + "category_slug": "authentication", + "defined_in_file": false + }, "TOWER_URL_BASE": { "type": "string", "label": "Base URL of the service", @@ -2897,6 +2905,15 @@ "category_slug": "system", "default": true }, + "DISABLE_LOCAL_AUTH": { + "type": "boolean", + "required": true, + "label": "Disable the built-in authentication system", + "help_text": "Controls whether users are prevented from using the built-in authentication system. You probably want to do this if you are using an LDAP or SAML integration.", + "category": "Authentication", + "category_slug": "authentication", + "default": false + }, "TOWER_URL_BASE": { "type": "string", "required": true, diff --git a/awx/ui_next/src/screens/Setting/shared/data.allSettings.json b/awx/ui_next/src/screens/Setting/shared/data.allSettings.json index 724361c5d9..98dd2e2468 100644 --- a/awx/ui_next/src/screens/Setting/shared/data.allSettings.json +++ b/awx/ui_next/src/screens/Setting/shared/data.allSettings.json @@ -3,6 +3,7 @@ "ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC":false, "ORG_ADMINS_CAN_SEE_ALL_USERS":true, "MANAGE_ORGANIZATION_AUTH":true, + "DISABLE_LOCAL_AUTH":false, "TOWER_URL_BASE":"https://localhost:3000", "REMOTE_HOST_HEADERS":["REMOTE_ADDR","REMOTE_HOST"], "PROXY_IP_ALLOWED_LIST":[],