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":[],