From 2ba5e06e2c18c484a6832a180f9bdd9c72218bc8 Mon Sep 17 00:00:00 2001 From: Chris Church Date: Fri, 2 Oct 2015 14:57:27 -0400 Subject: [PATCH] Add support for single-sign on using python-social-auth (with Google/Github OAuth2 and SAML support). Add support for RADIUS as another authentication backend. --- awx/api/authentication.py | 14 +++- awx/api/generics.py | 4 +- awx/api/serializers.py | 2 + awx/api/templates/api/_new_in_awx.md | 4 +- awx/api/urls.py | 1 + awx/api/views.py | 34 ++++++++++ awx/main/signals.py | 1 + awx/settings/defaults.py | 88 +++++++++++++++++++++++- awx/settings/development.py | 1 + awx/settings/local_settings.py.example | 55 +++++++++++++++ awx/settings/postprocess.py | 29 ++++++++ awx/settings/production.py | 1 + awx/sso/__init__.py | 2 + awx/sso/middleware.py | 90 +++++++++++++++++++++++++ awx/sso/models.py | 2 + awx/sso/pipeline.py | 23 +++++++ awx/sso/urls.py | 12 ++++ awx/sso/views.py | 85 +++++++++++++++++++++++ awx/static/img/favicon.ico | Bin 0 -> 5430 bytes awx/static/img/tower_console_bug.png | Bin 0 -> 2079 bytes awx/static/img/tower_console_logo.png | Bin 0 -> 3121 bytes awx/urls.py | 4 +- requirements/requirements.txt | 13 +++- 23 files changed, 458 insertions(+), 7 deletions(-) create mode 100644 awx/settings/postprocess.py create mode 100644 awx/sso/__init__.py create mode 100644 awx/sso/middleware.py create mode 100644 awx/sso/models.py create mode 100644 awx/sso/pipeline.py create mode 100644 awx/sso/urls.py create mode 100644 awx/sso/views.py create mode 100644 awx/static/img/favicon.ico create mode 100644 awx/static/img/tower_console_bug.png create mode 100644 awx/static/img/tower_console_logo.png diff --git a/awx/api/authentication.py b/awx/api/authentication.py index a40d546b3a..0cdc60d757 100644 --- a/awx/api/authentication.py +++ b/awx/api/authentication.py @@ -1,6 +1,9 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +# Python +import urllib + # Django from django.utils.timezone import now as tz_now from django.conf import settings @@ -30,6 +33,13 @@ class TokenAuthentication(authentication.TokenAuthentication): auth = auth.encode(HTTP_HEADER_ENCODING) return auth + @staticmethod + def _get_auth_token_cookie(request): + token = request.COOKIES.get('token', '') + if token: + token = urllib.unquote(token).strip('"') + return 'token %s' % token + def authenticate(self, request): self.request = request @@ -40,7 +50,9 @@ class TokenAuthentication(authentication.TokenAuthentication): if not auth or auth[0].lower() != 'token': auth = authentication.get_authorization_header(request).split() if not auth or auth[0].lower() != 'token': - return None + auth = TokenAuthentication._get_auth_token_cookie(request).split() + if not auth or auth[0].lower() != 'token': + return None if len(auth) == 1: msg = 'Invalid token header. No credentials provided.' diff --git a/awx/api/generics.py b/awx/api/generics.py index d0f90c8766..a0f892210c 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -142,6 +142,8 @@ class APIView(views.APIView): 'new_in_200': getattr(self, 'new_in_200', False), 'new_in_210': getattr(self, 'new_in_210', False), 'new_in_220': getattr(self, 'new_in_220', False), + 'new_in_230': getattr(self, 'new_in_230', False), + 'new_in_240': getattr(self, 'new_in_240', False), } def get_description(self, html=False): @@ -158,7 +160,7 @@ class APIView(views.APIView): ''' ret = super(APIView, self).metadata(request) added_in_version = '1.2' - for version in ('2.2.0', '2.1.0', '2.0.0', '1.4.8', '1.4.5', '1.4', '1.3'): + for version in ('2.4.0', '2.3.0', '2.2.0', '2.1.0', '2.0.0', '1.4.8', '1.4.5', '1.4', '1.3'): if getattr(self, 'new_in_%s' % version.replace('.', ''), False): added_in_version = version break diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c4434e259c..c53da8ada4 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -601,6 +601,8 @@ class UserSerializer(BaseSerializer): ret = super(UserSerializer, self).to_native(obj) ret.pop('password', None) ret.fields.pop('password', None) + if obj: + ret['auth'] = obj.social_auth.values('provider', 'uid') return ret def get_validation_exclusions(self): diff --git a/awx/api/templates/api/_new_in_awx.md b/awx/api/templates/api/_new_in_awx.md index e9d8967d67..f953afcc14 100644 --- a/awx/api/templates/api/_new_in_awx.md +++ b/awx/api/templates/api/_new_in_awx.md @@ -3,4 +3,6 @@ {% if new_in_145 %}> _Added in Ansible Tower 1.4.5_{% endif %} {% if new_in_148 %}> _Added in Ansible Tower 1.4.8_{% endif %} {% if new_in_200 %}> _New in Ansible Tower 2.0.0_{% endif %} -{% if new_in_220 %}> _New in Ansible Tower 2.2.0_{% endif %} \ No newline at end of file +{% if new_in_220 %}> _New in Ansible Tower 2.2.0_{% endif %} +{% if new_in_230 %}> _New in Ansible Tower 2.3.0_{% endif %} +{% if new_in_240 %}> _New in Ansible Tower 2.4.0_{% endif %} \ No newline at end of file diff --git a/awx/api/urls.py b/awx/api/urls.py index e2b4508e9f..d177d6b9ba 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -224,6 +224,7 @@ v1_urls = patterns('awx.api.views', url(r'^$', 'api_v1_root_view'), url(r'^ping/$', 'api_v1_ping_view'), url(r'^config/$', 'api_v1_config_view'), + url(r'^auth/$', 'auth_view'), url(r'^authtoken/$', 'auth_token_view'), url(r'^me/$', 'user_me_list'), url(r'^dashboard/$', 'dashboard_view'), diff --git a/awx/api/views.py b/awx/api/views.py index 71f25e0d94..4741c827c2 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -47,6 +47,9 @@ import qsstats # ANSIConv import ansiconv +# Python Social Auth +from social.backends.utils import load_backends + # AWX from awx.main.task_engine import TaskSerializer, TASK_FILE, TEMPORARY_TASK_FILE from awx.main.tasks import mongodb_control @@ -514,6 +517,37 @@ class ScheduleUnifiedJobsList(SubListAPIView): view_name = 'Schedule Jobs List' new_in_148 = True +class AuthView(APIView): + + authentication_classes = [] + permission_classes = (AllowAny,) + new_in_240 = True + + def get(self, request): + data = SortedDict() + err_backend, err_message = request.session.get('social_auth_error', (None, None)) + for name, backend in load_backends(settings.AUTHENTICATION_BACKENDS).items(): + login_url = reverse('social:begin', args=(name,)) + complete_url = request.build_absolute_uri(reverse('social:complete', args=(name,))) + backend_data = { + 'login_url': login_url, + 'complete_url': complete_url, + } + if name == 'saml': + backend_data['metadata_url'] = reverse('sso:saml_metadata') + for idp in settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.keys(): + saml_backend_data = dict(backend_data.items()) + saml_backend_data['login_url'] = '%s?idp=%s' % (login_url, idp) + full_backend_name = '%s:%s' % (name, idp) + if err_backend == full_backend_name and err_message: + saml_backend_data['error'] = err_message + data[full_backend_name] = saml_backend_data + else: + if err_backend == name and err_message: + backend_data['error'] = err_message + data[name] = backend_data + return Response(data) + class AuthTokenView(APIView): authentication_classes = [] diff --git a/awx/main/signals.py b/awx/main/signals.py index 2f426b74b3..4f34097d2f 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -407,4 +407,5 @@ def get_current_user_from_drf_request(sender, **kwargs): ''' request = get_current_request() drf_request = getattr(request, 'drf_request', None) + print drf_request return (getattr(drf_request, 'user', False), 0) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 10ce48d0e7..1ad51143f6 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -118,15 +118,30 @@ REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST'] STDOUT_MAX_BYTES_DISPLAY = 1048576 -TEMPLATE_CONTEXT_PROCESSORS += ( # NOQA +TEMPLATE_CONTEXT_PROCESSORS = ( # NOQA + 'django.contrib.auth.context_processors.auth', + 'django.core.context_processors.debug', + 'django.core.context_processors.i18n', + 'django.core.context_processors.media', + 'django.core.context_processors.static', + 'django.core.context_processors.tz', + 'django.contrib.messages.context_processors.messages', 'django.core.context_processors.request', 'awx.ui.context_processors.settings', 'awx.ui.context_processors.version', + 'social.apps.django_app.context_processors.backends', + 'social.apps.django_app.context_processors.login_redirect', ) -MIDDLEWARE_CLASSES += ( # NOQA +MIDDLEWARE_CLASSES = ( # NOQA + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', 'awx.main.middleware.HAMiddleware', 'awx.main.middleware.ActivityStreamMiddleware', + 'awx.sso.middleware.SocialAuthMiddleware', 'crum.CurrentRequestUserMiddleware', 'awx.main.middleware.AuthTokenTimeoutMiddleware', ) @@ -160,10 +175,12 @@ INSTALLED_APPS = ( 'kombu.transport.django', 'polymorphic', 'taggit', + 'social.apps.django_app.default', 'awx.main', 'awx.api', 'awx.ui', 'awx.fact', + 'awx.sso', ) INTERNAL_IPS = ('127.0.0.1',) @@ -201,12 +218,23 @@ REST_FRAMEWORK = { AUTHENTICATION_BACKENDS = ( 'awx.main.backend.LDAPBackend', + 'radiusauth.backends.RADIUSBackend', + 'social.backends.google.GoogleOAuth2', + 'social.backends.github.GithubOAuth2', + 'social.backends.github.GithubOrganizationOAuth2', + 'social.backends.github.GithubTeamOAuth2', + 'social.backends.saml.SAMLAuth', 'django.contrib.auth.backends.ModelBackend', ) # LDAP server (default to None to skip using LDAP authentication). AUTH_LDAP_SERVER_URI = None +# Radius server settings (default to empty string to skip using Radius auth). +RADIUS_SERVER = '' +RADIUS_PORT = 1812 +RADIUS_SECRET = '' + # Seconds before auth tokens expire. AUTH_TOKEN_EXPIRATION = 1800 @@ -312,6 +340,62 @@ CELERYBEAT_SCHEDULE = { }, } +# Social Auth configuration. +SOCIAL_AUTH_STRATEGY = 'social.strategies.django_strategy.DjangoStrategy' +SOCIAL_AUTH_STORAGE = 'social.apps.django_app.default.models.DjangoStorage' +SOCIAL_AUTH_USER_MODEL = AUTH_USER_MODEL +SOCIAL_AUTH_PIPELINE = ( + 'social.pipeline.social_auth.social_details', + 'social.pipeline.social_auth.social_uid', + 'social.pipeline.social_auth.auth_allowed', + 'social.pipeline.social_auth.social_user', + 'social.pipeline.user.get_username', + 'social.pipeline.social_auth.associate_by_email', + 'social.pipeline.mail.mail_validation', + 'social.pipeline.user.create_user', + 'social.pipeline.social_auth.associate_user', + 'social.pipeline.social_auth.load_extra_data', + 'awx.sso.pipeline.set_is_active_for_new_user', + 'social.pipeline.user.user_details', + 'awx.sso.pipeline.prevent_inactive_login', +) + +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '' +SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ['profile'] + +SOCIAL_AUTH_GITHUB_KEY = '' +SOCIAL_AUTH_GITHUB_SECRET = '' + +SOCIAL_AUTH_GITHUB_ORG_KEY = '' +SOCIAL_AUTH_GITHUB_ORG_SECRET = '' +SOCIAL_AUTH_GITHUB_ORG_NAME = '' + +SOCIAL_AUTH_GITHUB_TEAM_KEY = '' +SOCIAL_AUTH_GITHUB_TEAM_SECRET = '' +SOCIAL_AUTH_GITHUB_TEAM_ID = '' + +SOCIAL_AUTH_SAML_SP_ENTITY_ID = '' +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = '' +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = '' +SOCIAL_AUTH_SAML_ORG_INFO = {} +SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = {} +SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {} +SOCIAL_AUTH_SAML_ENABLED_IDPS = {} + +SOCIAL_AUTH_LOGIN_URL = '/' +SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/sso/complete/' +SOCIAL_AUTH_LOGIN_ERROR_URL = '/sso/error/' +SOCIAL_AUTH_INACTIVE_USER_URL = '/sso/inactive/' + +SOCIAL_AUTH_RAISE_EXCEPTIONS = False +SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL = False +SOCIAL_AUTH_SLUGIFY_USERNAMES = True +SOCIAL_AUTH_CLEAN_USERNAMES = True + +SOCIAL_AUTH_SANITIZE_REDIRECTS = True +SOCIAL_AUTH_REDIRECT_IS_HTTPS = False + # Any ANSIBLE_* settings will be passed to the subprocess environment by the # celery task. diff --git a/awx/settings/development.py b/awx/settings/development.py index ead9e44a29..facdc2ca54 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -78,6 +78,7 @@ include(optional('/etc/tower/conf.d/*.py'), scope=locals()) try: include( optional('local_*.py'), + 'postprocess.py', scope=locals(), ) except ImportError: diff --git a/awx/settings/local_settings.py.example b/awx/settings/local_settings.py.example index 34d65e163f..b83f5751ff 100644 --- a/awx/settings/local_settings.py.example +++ b/awx/settings/local_settings.py.example @@ -470,6 +470,61 @@ TEST_AUTH_LDAP_TEAM_MAP_2_RESULT = { 'Everyone Team': {'users': True}, } +############################################################################### +# RADIUS AUTH SETTINGS +############################################################################### + +RADIUS_SERVER = '' +RADIUS_PORT = 1812 +RADIUS_SECRET = '' + +############################################################################### +# SOCIAL AUTH SETTINGS +############################################################################### + +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '' +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '' +#SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ['profile'] +#SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS = ['example.com'] +#SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS = {'hd': 'example.com'} + +SOCIAL_AUTH_GITHUB_KEY = '' +SOCIAL_AUTH_GITHUB_SECRET = '' + +SOCIAL_AUTH_GITHUB_ORG_KEY = '' +SOCIAL_AUTH_GITHUB_ORG_SECRET = '' +SOCIAL_AUTH_GITHUB_ORG_NAME = '' + +SOCIAL_AUTH_GITHUB_TEAM_KEY = '' +SOCIAL_AUTH_GITHUB_TEAM_SECRET = '' +SOCIAL_AUTH_GITHUB_TEAM_ID = '' + +SOCIAL_AUTH_SAML_SP_ENTITY_ID = '' +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = '' +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = '' +SOCIAL_AUTH_SAML_ORG_INFO = { + 'en-US': { + 'name': 'example', + 'displayname': 'Example', + 'url': 'http://www.example.com', + }, +} +SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = { + 'givenName': 'Some User', + 'emailAddress': 'suser@example.com', +} +SOCIAL_AUTH_SAML_SUPPORT_CONTACT = { + 'givenName': 'Some User', + 'emailAddress': 'suser@example.com', +} +SOCIAL_AUTH_SAML_ENABLED_IDPS = { + #'myidp': { + # 'entity_id': 'https://idp.example.com', + # 'url': 'https://myidp.example.com/sso', + # 'x509cert': '', + #}, +} + ############################################################################### # INVENTORY IMPORT TEST SETTINGS ############################################################################### diff --git a/awx/settings/postprocess.py b/awx/settings/postprocess.py new file mode 100644 index 0000000000..0fe024f27a --- /dev/null +++ b/awx/settings/postprocess.py @@ -0,0 +1,29 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Runs after all configuration files have been loaded to fix/check/update +# settings as needed. + +if not AUTH_LDAP_SERVER_URI: + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.main.backend.LDAPBackend'] + +if not RADIUS_SERVER: + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'radiusauth.backends.RADIUSBackend'] + +if not all([SOCIAL_AUTH_GOOGLE_OAUTH2_KEY, SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET]): + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'social.backends.google.GoogleOAuth2'] + +if not all([SOCIAL_AUTH_GITHUB_KEY, SOCIAL_AUTH_GITHUB_SECRET]): + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'social.backends.github.GithubOAuth2'] + +if not all([SOCIAL_AUTH_GITHUB_ORG_KEY, SOCIAL_AUTH_GITHUB_ORG_SECRET, SOCIAL_AUTH_GITHUB_ORG_NAME]): + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'social.backends.github.GithubOrganizationOAuth2'] + +if not all([SOCIAL_AUTH_GITHUB_TEAM_KEY, SOCIAL_AUTH_GITHUB_TEAM_SECRET, SOCIAL_AUTH_GITHUB_TEAM_ID]): + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'social.backends.github.GithubTeamOAuth2'] + +if not all([SOCIAL_AUTH_SAML_SP_ENTITY_ID, SOCIAL_AUTH_SAML_SP_PUBLIC_CERT, + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY, SOCIAL_AUTH_SAML_ORG_INFO, + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT, SOCIAL_AUTH_SAML_SUPPORT_CONTACT, + SOCIAL_AUTH_SAML_ENABLED_IDPS]): + AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'social.backends.saml.SAMLAuth'] diff --git a/awx/settings/production.py b/awx/settings/production.py index 32472e8548..c4980257e4 100644 --- a/awx/settings/production.py +++ b/awx/settings/production.py @@ -111,6 +111,7 @@ try: include( settings_file, optional(settings_files), + 'postprocess.py', scope=locals(), ) except ImportError: diff --git a/awx/sso/__init__.py b/awx/sso/__init__.py new file mode 100644 index 0000000000..e484e62be1 --- /dev/null +++ b/awx/sso/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. diff --git a/awx/sso/middleware.py b/awx/sso/middleware.py new file mode 100644 index 0000000000..49d07482f6 --- /dev/null +++ b/awx/sso/middleware.py @@ -0,0 +1,90 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Python +import urllib + +# Six +import six + +# Django +from django.contrib.auth import logout +from django.shortcuts import redirect +from django.utils.timezone import now + +# Python Social Auth +from social.exceptions import SocialAuthBaseException +from social.utils import social_logger +from social.apps.django_app.middleware import SocialAuthExceptionMiddleware + +from awx.main.models import AuthToken + +class SocialAuthMiddleware(SocialAuthExceptionMiddleware): + + def process_request(self, request): + request.META['SERVER_PORT'] = 80 # FIXME + + token_key = request.COOKIES.get('token', '') + token_key = urllib.quote(urllib.unquote(token_key).strip('"')) + + if not hasattr(request, 'successful_authenticator'): + request.successful_authenticator = None + + if not request.path.startswith('/sso/'): + + # If token isn't present but we still have a user logged in via Django + # sessions, log them out. + if not token_key and request.user and request.user.is_authenticated(): + logout(request) + + # If a token is present, make sure it matches a valid one in the + # database, and log the user via Django session if necessary. + # Otherwise, log the user out via Django sessions. + elif token_key: + + try: + auth_token = AuthToken.objects.filter(key=token_key, expires__gt=now())[0] + except IndexError: + auth_token = None + + if not auth_token and request.user and request.user.is_authenticated(): + logout(request) + elif auth_token and request.user != auth_token.user: + logout(request) + login(request, auth_token.user) + auth_token.refresh() + + if auth_token and request.user and request.user.is_authenticated(): + request.session.pop('social_auth_error', None) + + def process_exception(self, request, exception): + strategy = getattr(request, 'social_strategy', None) + if strategy is None or self.raise_exception(request, exception): + return + + if isinstance(exception, SocialAuthBaseException): + backend = getattr(request, 'backend', None) + backend_name = getattr(backend, 'name', 'unknown-backend') + full_backend_name = backend_name + try: + idp_name = strategy.request_data()['RelayState'] + full_backend_name = '%s:%s' % (backend_name, idp_name) + except KeyError: + pass + + message = self.get_message(request, exception) + social_logger.error(message) + + url = self.get_redirect_uri(request, exception) + request.session['social_auth_error'] = (full_backend_name, message) + return redirect(url) + + def get_message(self, request, exception): + msg = six.text_type(exception) + if msg and msg[-1] not in '.?!': + msg = msg + '.' + return msg + + def get_redirect_uri(self, request, exception): + strategy = getattr(request, 'social_strategy', None) + return strategy.session_get('next', '') or strategy.setting('LOGIN_ERROR_URL') diff --git a/awx/sso/models.py b/awx/sso/models.py new file mode 100644 index 0000000000..e484e62be1 --- /dev/null +++ b/awx/sso/models.py @@ -0,0 +1,2 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. diff --git a/awx/sso/pipeline.py b/awx/sso/pipeline.py new file mode 100644 index 0000000000..e11b115cd6 --- /dev/null +++ b/awx/sso/pipeline.py @@ -0,0 +1,23 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Python Social Auth +from social.exceptions import AuthException + + +class AuthInactive(AuthException): + """Authentication for this user is forbidden""" + + def __str__(self): + return 'Your account is inactive' + + +def set_is_active_for_new_user(strategy, details, user=None, *args, **kwargs): + if kwargs.get('is_new', False): + details['is_active'] = True + return {'details': details} + + +def prevent_inactive_login(backend, details, user=None, *args, **kwargs): + if user and not user.is_active: + raise AuthInactive(backend) diff --git a/awx/sso/urls.py b/awx/sso/urls.py new file mode 100644 index 0000000000..9de510e9a8 --- /dev/null +++ b/awx/sso/urls.py @@ -0,0 +1,12 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Django +from django.conf.urls import include, patterns, url + +urlpatterns = patterns('awx.sso.views', + url(r'^complete/$', 'sso_complete', name='sso_complete'), + url(r'^error/$', 'sso_error', name='sso_error'), + url(r'^inactive/$', 'sso_inactive', name='sso_inactive'), + url(r'^metadata/saml/$', 'saml_metadata', name='saml_metadata'), +) diff --git a/awx/sso/views.py b/awx/sso/views.py new file mode 100644 index 0000000000..d8832ab32e --- /dev/null +++ b/awx/sso/views.py @@ -0,0 +1,85 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Python +import urllib + +# Django +from django.contrib.auth import logout as auth_logout +from django.core.urlresolvers import reverse +from django.http import HttpResponse +from django.utils.timezone import now, utc +from django.views.generic import View +from django.views.generic.base import RedirectView + +# Django REST Framework +from rest_framework.renderers import JSONRenderer + +# Ansible Tower +from awx.main.models import AuthToken +from awx.api.serializers import UserSerializer + + +class BaseRedirectView(RedirectView): + + def get_redirect_url(self, *args, **kwargs): + last_path = self.request.COOKIES.get('lastPath', '') + last_path = urllib.quote(urllib.unquote(last_path).strip('"')) + url = reverse('ui:index') + if last_path: + return '%s#%s' % (url, last_path) + else: + return url + +sso_error = BaseRedirectView.as_view() +sso_inactive = BaseRedirectView.as_view() + + +class CompleteView(BaseRedirectView): + + def dispatch(self, request, *args, **kwargs): + response = super(CompleteView, self).dispatch(request, *args, **kwargs) + if self.request.user and self.request.user.is_authenticated(): + request_hash = AuthToken.get_request_hash(self.request) + try: + token = AuthToken.objects.filter(user=request.user, + request_hash=request_hash, + expires__gt=now())[0] + token.refresh() + except IndexError: + token = AuthToken.objects.create(user=request.user, + request_hash=request_hash) + request.session['auth_token_key'] = token.key + token_key = urllib.quote('"%s"' % token.key) + response.set_cookie('token', token_key) + token_expires = token.expires.astimezone(utc).strftime('%Y-%m-%dT%H:%M:%S') + token_expires = '%s.%03dZ' % (token_expires, token.expires.microsecond / 1000) + token_expires = urllib.quote('"%s"' % token_expires) + response.set_cookie('token_expires', token_expires) + response.set_cookie('userLoggedIn', 'true') + current_user = UserSerializer(self.request.user) + current_user = JSONRenderer().render(current_user.data) + current_user = urllib.quote('%s' % current_user, '') + response.set_cookie('current_user', current_user) + return response + +sso_complete = CompleteView.as_view() + + +class MetadataView(View): + + def get(self, request, *args, **kwargs): + from social.apps.django_app.utils import load_backend, load_strategy + complete_url = reverse('social:complete', args=('saml', )) + saml_backend = load_backend( + load_strategy(request), + 'saml', + redirect_uri=complete_url, + ) + metadata, errors = saml_backend.generate_metadata_xml() + if not errors: + return HttpResponse(content=metadata, content_type='text/xml') + else: + return HttpResponse(content=str(errors), content_type='text/plain') + +saml_metadata = MetadataView.as_view() diff --git a/awx/static/img/favicon.ico b/awx/static/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f53629b961508ea2f031df98bdabefd57143a620 GIT binary patch literal 5430 zcmc&%txhCC5T3oPL@W-u>T(GNfd#c>u%; z9sq$ru0S9Vec#vD-=wDLne9C;p^~D1>gTJjuC7^%qEmb;dc6YFFMf85;yY*kzw6)F z|AoCDSW<=C9sjo|2H20uIoGia`6_q_o`PqAIp^N0)s5$npMd9J*T9sW#CBX`f81bG z^R=?F!u8ntpikO9KR>&NhliTJe6Fpnxw0(X%gc-8J`TuvULW(>*%{nAx3#q;9Y1nS z-=zEdd&y{{T=_j>yuZJ@o0}WyxWmIk_xASYZTaWm;GmMhUU{zeZf|eh>+7rQ^?L5= z>dM>AW-}i?I^3^%2p%fs>vzlv%s7u-!D=R|NqYHGkx#?tUfdHy$KtJ6kqNL z(>?G-;1VNQ>%Ck*%jm&BF_|ly^R_v4KOGBvY=U{dHmo)oZ8w)Wt8`(~%?N{==o zerK#~(VX$x_~|FI`B0OPmsW@ITZ-}+`>WRDF|ikMGgq~f%9Eq#rwfs_p~p(Fezo!UL7>M(WJ z!sd8-dfMol_=qE!d&N8&jVgC>9a@=`?ESapEchq(g|Jd1zqOW_Yu*EE$`Y~m_xJsI zwZEul0(L7tW3pJRG3LP%*ZNJRH&1!Sn;~|YI=9_p{WcNLsJgUz@m*PeW=`yd_|&)O zQY>+8*`AfERfDUVB=*Am(~ejo-gW$@nFA5!39Qr}v9s{$pMFKkxo~WJd;{8FFcN!j zfllSi<~Pp0y&u(LhF{+UGxU56tY;DNWM}Ix(d#Qc>1%{9^}l5Qm7X1Kk5>)Y_sJe$ z^;_n)|7PET{U-1}Y=5#jX~9)|KKCXEWrmHnwRLFwZCn#A{z~C$gl_HkM~khqn6R%X zeiJP|KK%ZGlMeLx9+aQP(dJy>yo(%LVi2o|A)n;69t{hS!=o6GF= zS=fpLF)X|be*c+=jI+1qT$u5Q7{2WPFB=QuV1LN&Q9-x%zOBE{%tI`^8*&*~>!mSa H|E2T?(AG#m literal 0 HcmV?d00001 diff --git a/awx/static/img/tower_console_bug.png b/awx/static/img/tower_console_bug.png new file mode 100644 index 0000000000000000000000000000000000000000..90737558ae161af808558e02e238942eac197219 GIT binary patch literal 2079 zcmaJ?X;f3!77j>>B11uOK=56IC@N%v011RC36e;Fk{}4;G9(uWWW2db2-XGxEBg9G z1_4o|2%>bbLIoj=50qJk*w?3MD~O=LdMrT&rAlwC*!QFKu66G@`>gN#_C9-mYu$|C zz}+TBRz@fk%7p1p4?$Kp-Mi8d`Cd8hz8P6I!i;d3BaML*c@T*55lEv!fGOt1f*~MJ zkR7$G+t4rd2Y_);;Jr^8^CVj03lp**~lG9Etxgn?)ig~X~LH4?190qs~ zffJ~hKZ*)x2Lm)I1Og;1-jR=Y!2>P?EWydy)z#GjAmRyl9G-|H5FH7w6el7Dj|VsmVK0!C_c0aX&6q)li>(hyzWX%KsNjDp}LW4jaEQPpIsc5N-!EsVps)&=y02+q` zuz7r;L>Iruv)L4;L;>?8e2__}Vh{zaP$;1IkbUVsZbTPXl9LaCz;N;<`?}B=L_ZSV znc>WE#V>K`QvM+^D1n!_f%sXnatDxE(@l-OBouj6xY?GwD8D<^8F1d;Gbd zZD{WcSTRT^-nTz#8$thU%fB?D?3g`eLTz;cJJO^k#c1G858J2kGWd;gWc-BR5DBwm zRr;`h*6Fqm3nTZ%=l?89uAg3bD9`e|+0#1tU_tZiV)FUj2V-Nuv;kLF+DdkB#^-#o zW~ITAhW-mR9LK@-9&LB;rlV1NOf}x`R#*b)tK+y;N1bPEv8J{w1jqleMqhQsUXRR8 zYufr+KBV8#g);6Kw?6z^zg~Kc%E0 zKPhN~Oif11=B^N#UBvadcSnRfPNfG0zkQ)D;HGY~VXZH74$94mYt-B+V+qWit{SrI zcgOS&uxy?hRfMP6rE|X3yOkkmuq$23v`RYLubNW*kUdjTvG(WcnzE))&Vij**Pl|U z1;3;kZ0hcQ6`3iW6e)%BX{V9O>))QYAo7gt{T`Zkjar-?5kX)s`#FEIZ@OXn{?jaz zspGlm$har_zB@DKJO_y4K?a9-*LboJT^m(aXHmNS*ZI*2&$~}&D#!M-a61)VXw)+! zP1Y9hT1(;6m?Jwk%ALHk=2OQMU{|TO9BTQw@5VWqpFmiwI9i*{{JJaO*$VEw3HQ49haJ z?$jGHuFH-vMf=xMQ|V46@MyLPud^7P))sGv8y*8uN|&TD2Tf>L?1qaK^-=#ljxEkB z5ZGo6k6rBPn|h|s`kCgPwjHF z@_1XkqiFWciP2j!ER*q!nje63O=`1~;lX3Pb8`^%1wtDu=Cr4x5G^1_FbwTZ-iN-whzSD06nLWaN zMb$r}$)oOj=X3X$ALc~0Csumq&kQzNHV^JCTFKf7jmU|D4Vv?J80g-0pZQ8&`qFcp*0spCbNLo0npQk^Sog^3+R9py* zDhXT{UC!yclDS}L59pzTy{h4_R$n6*v7RT?@O;%FQ!dtUrE71Z_k|6NBhg#Rf5Gfs u-7oO~6g)^$* literal 0 HcmV?d00001 diff --git a/awx/static/img/tower_console_logo.png b/awx/static/img/tower_console_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c93291a1c9a0183cba043cab05500132732a36dc GIT binary patch literal 3121 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 literal 0 HcmV?d00001 diff --git a/awx/urls.py b/awx/urls.py index c624142220..f24ae22e58 100644 --- a/awx/urls.py +++ b/awx/urls.py @@ -9,7 +9,9 @@ handler500 = 'awx.main.views.handle_500' urlpatterns = patterns('', url(r'', include('awx.ui.urls', namespace='ui', app_name='ui')), - url(r'^api/', include('awx.api.urls', namespace='api', app_name='api'))) + url(r'^api/', include('awx.api.urls', namespace='api', app_name='api')), + url(r'^sso/', include('awx.sso.urls', namespace='sso', app_name='sso')), + url(r'^sso/', include('social.apps.django_app.urls', namespace='social'))) urlpatterns += patterns('awx.main.views', url(r'^403.html$', 'handle_403'), diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 451ff951d7..6e9297abde 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -13,15 +13,18 @@ cliff==1.13.0 cmd2==0.6.8 cryptography==0.9.3 d2to1==0.2.11 +defusedxml==0.4.1 Django==1.6.7 django-auth-ldap==1.2.6 django-celery==3.1.10 django-crum==0.6.1 django-extensions==1.3.3 django-polymorphic==0.5.3 +django-radius==0.1.1 djangorestframework==2.3.13 django-split-settings==0.1.1 django-taggit==0.11.2 +dm.xmlsec.binding==1.3.2 dogpile.cache==0.5.6 dogpile.core==0.4.1 enum34==1.0.4 @@ -49,12 +52,14 @@ jsonschema==2.5.1 keyring==4.1 kombu==3.0.21 lxml==3.4.4 +M2Crypto==0.22.3 Markdown==2.4.1 mock==1.0.1 mongoengine==0.9.0 msgpack-python==0.4.6 netaddr==0.7.14 netifaces==0.10.4 +oauthlib==1.0.3 ordereddict==1.1 os-client-config==1.6.1 os-diskconfig-python-novaclient-ext==0.1.2 @@ -74,6 +79,7 @@ psycopg2 pyasn1==0.1.8 pycparser==2.14 pycrypto==2.6.1 +PyJWT==1.4.0 pymongo==2.8 pyOpenSSL==0.15.1 pyparsing==2.0.3 @@ -85,6 +91,10 @@ python-ironicclient==0.5.0 python-ldap==2.4.20 python-neutronclient==2.3.11 python-novaclient==2.20.0 +python-openid==2.2.5 +python-radius==1.0 +python_social_auth==0.2.13 +python-saml==2.1.4 python-swiftclient==2.2.0 python-troveclient==1.0.9 pytz==2014.10 @@ -97,9 +107,10 @@ rax-default-network-flags-python-novaclient-ext==0.2.3 rax-scheduled-images-python-novaclient-ext==0.2.1 redis==2.10.3 requests==2.5.1 +requests-oauthlib==0.5.0 simplejson==3.6.0 six==1.9.0 -South==0.8.4 +South==1.0.2 stevedore==1.3.0 suds==0.4 warlock==1.1.0