Merge pull request #10102 from jbradberry/disable-local-users

Add the ability to disable local authentication

SUMMARY
When an external authentication system is enabled, users would like the ability to disable local authentication for enhanced security.
related #4553
TODO

 create a configure-Tower-in-Tower setting,  DISABLE_LOCAL_AUTH
 expose the setting in the settings UI
 be able to query out all local-only users

User.objects.filter(Q(profile__isnull=True) | Q(profile__ldap_dn=''), enterprise_auth__isnull=True, social_auth__isnull=True)
see: awx/main/utils/common.py, get_external_account


 write a thin wrapper around the Django model-based auth backend
 update the UI tests to include the new setting
 be able to trigger a side-effect when this setting changes
 revoke all OAuth2 tokens for users that do not have a remote
auth backend associated with them
 revoke sessions for local-only users

ultimately I did this by adding a new middleware that checks the value of this new setting and force-logouts any local-only user making a request after it is enabled


 settings API endpoint raises a validation error if there are no external users or auth sources configured

The remote user existence validation has been removed, since ultimately we can't know for sure if a sysadmin-level user will still have access to the UI.  This is being dealt with by using a confirmation modal, see below.


 add a modal asking the user to confirm that they want to turn this setting on

ISSUE TYPE


Feature Pull Request

COMPONENT NAME


API
UI

AWX VERSION

Reviewed-by: Jeff Bradberry <None>
Reviewed-by: Bianca Henderson <beeankha@gmail.com>
Reviewed-by: Mat Wilson <mawilson@redhat.com>
Reviewed-by: Michael Abashian <None>
Reviewed-by: Chris Meyers <None>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-05-27 18:37:47 +00:00 committed by GitHub
commit c29a7ccf8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 273 additions and 16 deletions

View File

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

View File

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

14
awx/main/backends.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -196,6 +196,7 @@ class AuthenticationBackendsField(fields.StringListField):
],
),
('django.contrib.auth.backends.ModelBackend', []),
('awx.main.backends.AWXModelBackend', []),
]
)

View File

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

View File

@ -30,6 +30,7 @@ describe('<MiscSystemDetail />', () => {
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,

View File

@ -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}
/>
<BooleanField
name="DISABLE_LOCAL_AUTH"
needsConfirmationModal
modalTitle={t`Confirm Disable Local Authorization`}
config={system.DISABLE_LOCAL_AUTH}
/>
<InputField
name="SESSION_COOKIE_AGE"
config={system.SESSION_COOKIE_AGE}

View File

@ -31,6 +31,7 @@ const systemData = {
INSIGHTS_TRACKING_STATE: false,
LOGIN_REDIRECT_OVERRIDE: '',
MANAGE_ORGANIZATION_AUTH: true,
DISABLE_LOCAL_AUTH: false,
OAUTH2_PROVIDER: {
ACCESS_TOKEN_EXPIRE_SECONDS: 31536000000,
AUTHORIZATION_CODE_EXPIRE_SECONDS: 600,

View File

@ -3,6 +3,7 @@ import { shape, string } from 'prop-types';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import {
Button,
FileUpload,
FormGroup as PFFormGroup,
InputGroup,
@ -25,6 +26,7 @@ import {
url,
} from '../../../util/validators';
import RevertButton from './RevertButton';
import AlertModal from '../../../components/AlertModal';
const FormGroup = styled(PFFormGroup)`
.pf-c-form__group-label {
@ -73,8 +75,56 @@ const SettingGroup = ({
</FormGroup>
);
};
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 (
<AlertModal
isOpen
title={modalTitle}
variant="danger"
aria-label={modalTitle}
onClose={() => {
helpers.setValue(false);
}}
actions={[
<Button
ouiaId="confirm-misc-settings-modal"
key="confirm"
variant="danger"
aria-label={t`Confirm`}
onClick={() => {
helpers.setValue(true);
setIsModalOpen(false);
}}
>
{t`Confirm`}
</Button>,
<Button
ouiaId="cancel-misc-settings-modal"
key="cancel"
variant="link"
aria-label={t`Cancel`}
onClick={() => {
helpers.setValue(false);
setIsModalOpen(false);
}}
>
{t`Cancel`}
</Button>,
]}
>{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.`}</AlertModal>
);
}
return config ? (
<SettingGroup
@ -92,7 +142,11 @@ const BooleanField = ({ ariaLabel = '', name, config, disabled = false }) => {
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}
/>
</SettingGroup>

View File

@ -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(
<Formik
initialValues={{
DISABLE_LOCAL_AUTH: false,
}}
>
{() => (
<BooleanField
name="DISABLE_LOCAL_AUTH"
needsConfirmationModal
modalTitle="Confirm Disable Local Authorization"
config={{
category: 'Authentication',
category_slug: 'authentication',
default: false,
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.',
label: 'Disable the built-in authentication system',
required: true,
type: 'boolean',
value: false,
}}
/>
)}
</Formik>
);
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(
<Formik
initialValues={{
DISABLE_LOCAL_AUTH: false,
}}
>
{() => (
<BooleanField
name="DISABLE_LOCAL_AUTH"
needsConfirmationModal
modalTitle="Confirm Disable Local Authorization"
config={{
category: 'Authentication',
category_slug: 'authentication',
default: false,
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.',
label: 'Disable the built-in authentication system',
required: true,
type: 'boolean',
value: false,
}}
/>
)}
</Formik>
);
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);
});
});

View File

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

View File

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