diff options
-rw-r--r-- | awx/api/authentication.py | 14 | ||||
-rw-r--r-- | awx/api/generics.py | 4 | ||||
-rw-r--r-- | awx/api/serializers.py | 2 | ||||
-rw-r--r-- | awx/api/templates/api/_new_in_awx.md | 4 | ||||
-rw-r--r-- | awx/api/urls.py | 1 | ||||
-rw-r--r-- | awx/api/views.py | 34 | ||||
-rw-r--r-- | awx/main/signals.py | 1 | ||||
-rw-r--r-- | awx/settings/defaults.py | 88 | ||||
-rw-r--r-- | awx/settings/development.py | 1 | ||||
-rw-r--r-- | awx/settings/local_settings.py.example | 55 | ||||
-rw-r--r-- | awx/settings/postprocess.py | 29 | ||||
-rw-r--r-- | awx/settings/production.py | 1 | ||||
-rw-r--r-- | awx/sso/__init__.py | 2 | ||||
-rw-r--r-- | awx/sso/middleware.py | 90 | ||||
-rw-r--r-- | awx/sso/models.py | 2 | ||||
-rw-r--r-- | awx/sso/pipeline.py | 23 | ||||
-rw-r--r-- | awx/sso/urls.py | 12 | ||||
-rw-r--r-- | awx/sso/views.py | 85 | ||||
-rw-r--r-- | awx/static/img/favicon.ico | bin | 0 -> 5430 bytes | |||
-rw-r--r-- | awx/static/img/tower_console_bug.png | bin | 0 -> 2079 bytes | |||
-rw-r--r-- | awx/static/img/tower_console_logo.png | bin | 0 -> 3121 bytes | |||
-rw-r--r-- | awx/urls.py | 4 | ||||
-rw-r--r-- | requirements/requirements.txt | 13 |
23 files changed, 458 insertions, 7 deletions
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 @@ -471,6 +471,61 @@ TEST_AUTH_LDAP_TEAM_MAP_2_RESULT = { } ############################################################################### +# 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 Binary files differnew file mode 100644 index 0000000000..f53629b961 --- /dev/null +++ b/awx/static/img/favicon.ico diff --git a/awx/static/img/tower_console_bug.png b/awx/static/img/tower_console_bug.png Binary files differnew file mode 100644 index 0000000000..90737558ae --- /dev/null +++ b/awx/static/img/tower_console_bug.png diff --git a/awx/static/img/tower_console_logo.png b/awx/static/img/tower_console_logo.png Binary files differnew file mode 100644 index 0000000000..c93291a1c9 --- /dev/null +++ b/awx/static/img/tower_console_logo.png 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 |