summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--awx/api/authentication.py14
-rw-r--r--awx/api/generics.py4
-rw-r--r--awx/api/serializers.py2
-rw-r--r--awx/api/templates/api/_new_in_awx.md4
-rw-r--r--awx/api/urls.py1
-rw-r--r--awx/api/views.py34
-rw-r--r--awx/main/signals.py1
-rw-r--r--awx/settings/defaults.py88
-rw-r--r--awx/settings/development.py1
-rw-r--r--awx/settings/local_settings.py.example55
-rw-r--r--awx/settings/postprocess.py29
-rw-r--r--awx/settings/production.py1
-rw-r--r--awx/sso/__init__.py2
-rw-r--r--awx/sso/middleware.py90
-rw-r--r--awx/sso/models.py2
-rw-r--r--awx/sso/pipeline.py23
-rw-r--r--awx/sso/urls.py12
-rw-r--r--awx/sso/views.py85
-rw-r--r--awx/static/img/favicon.icobin0 -> 5430 bytes
-rw-r--r--awx/static/img/tower_console_bug.pngbin0 -> 2079 bytes
-rw-r--r--awx/static/img/tower_console_logo.pngbin0 -> 3121 bytes
-rw-r--r--awx/urls.py4
-rw-r--r--requirements/requirements.txt13
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
new file mode 100644
index 0000000000..f53629b961
--- /dev/null
+++ b/awx/static/img/favicon.ico
Binary files differ
diff --git a/awx/static/img/tower_console_bug.png b/awx/static/img/tower_console_bug.png
new file mode 100644
index 0000000000..90737558ae
--- /dev/null
+++ b/awx/static/img/tower_console_bug.png
Binary files differ
diff --git a/awx/static/img/tower_console_logo.png b/awx/static/img/tower_console_logo.png
new file mode 100644
index 0000000000..c93291a1c9
--- /dev/null
+++ b/awx/static/img/tower_console_logo.png
Binary files differ
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