Browse Source

auth: improvements to saml2 auth provider

Matt Robenolt 7 years ago
parent
commit
2b4c870c08

+ 1 - 0
.travis.yml

@@ -18,6 +18,7 @@ addons:
       - ubuntu-toolchain-r-test
     packages:
       - g++-4.8
+      - libxmlsec1-dev
 env:
   global:
     - NODE_ENV=production

+ 1 - 0
Brewfile

@@ -1,3 +1,4 @@
+brew 'libxmlsec1'
 brew 'nodejs'
 brew 'openssl'
 brew 'postgresql'

+ 2 - 2
Makefile

@@ -31,10 +31,10 @@ install-python-base:
 	# order matters here, base package must install first
 	pip install -e .
 	pip install ujson
-	pip install "file://`pwd`#egg=sentry[dev]"
+	pip install "file://`pwd`#egg=sentry[dev,saml]"
 
 install-python-tests:
-	pip install "file://`pwd`#egg=sentry[dev,tests,dsym]"
+	pip install "file://`pwd`#egg=sentry[dev,tests,saml]"
 
 dev-postgres: install-python
 

+ 5 - 0
setup.py

@@ -144,6 +144,10 @@ install_requires = [
     'python-u2flib-server>=4.0.1,<4.1.0',
 ]
 
+saml_requires = [
+    'python3-saml>=1.2.6,<1.3',
+]
+
 
 class SentrySDistCommand(SDistCommand):
     # If we are not a light build we want to also execute build_assets as
@@ -192,6 +196,7 @@ setup(
     extras_require={
         'dev': dev_requires,
         'postgres': [],
+        'saml': saml_requires,
         'tests': tests_require,
     },
     cmdclass=cmdclass,

+ 5 - 1
src/sentry/auth/helper.py

@@ -190,6 +190,11 @@ class AuthHelper(object):
         user = request.user
         organization = self.organization
 
+        # On SAML we don't have an id when doing the setup so we
+        # don't gonna attach the identity if that was not provided
+        if not identity:
+            return
+
         try:
             try:
                 # prioritize identifying by the SSO provider's user ID
@@ -415,7 +420,6 @@ class AuthHelper(object):
         """
         request = self.request
         op = request.POST.get('op')
-
         if not request.user.is_authenticated():
             # TODO(dcramer): its possible they have multiple accounts and at
             # least one is managed (per the check below)

+ 266 - 0
src/sentry/auth/providers/saml2.py

@@ -0,0 +1,266 @@
+from __future__ import absolute_import, print_function
+
+from django.conf import settings
+from django.core.urlresolvers import reverse
+from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseServerError)
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_exempt
+
+from sentry.auth import Provider, AuthView
+from sentry.auth.exceptions import IdentityNotValid
+from sentry.models import (AuthProvider, Organization, OrganizationStatus, User, UserEmail)
+from sentry.utils.http import absolute_uri
+from sentry.utils.auth import login, get_login_redirect
+
+try:
+    from onelogin.saml2.auth import OneLogin_Saml2_Auth, OneLogin_Saml2_Settings
+except ImportError:
+
+    def OneLogin_Saml2_Auth(*args, **kwargs):
+        raise NotImplementedError('Missing SAML libraries')
+
+    def OneLogin_Saml2_Settings(*args, **kwargs):
+        raise NotImplementedError('Missing SAML libraries')
+
+
+def get_provider(organization_slug):
+    try:
+        organization = Organization.objects.get(slug=organization_slug)
+    except Organization.DoesNotExist:
+        return HttpResponseRedirect(reverse('sentry-login'))
+
+    if organization.status != OrganizationStatus.VISIBLE:
+        return HttpResponseRedirect(reverse('sentry-login'))
+
+    try:
+        auth_provider = AuthProvider.objects.get(organization=organization)
+        return auth_provider.get_provider()
+    except AuthProvider.DoesNotExist:
+        return HttpResponseRedirect(reverse('sentry-login'))
+
+
+class SAML2LoginView(AuthView):
+    def dispatch(self, request, helper):
+        provider = helper.provider
+        saml_config = provider.build_saml_config(helper.organization.slug)
+        auth = provider.build_auth(request, saml_config)
+        return self.redirect(auth.login())
+
+
+class SAML2ACSView(AuthView):
+    @method_decorator(csrf_exempt)
+    def dispatch(self, request, organization_slug):
+        provider = get_provider(organization_slug)
+        organization = Organization.objects.get(slug=organization_slug)
+        saml_config = provider.build_saml_config(organization_slug)
+
+        auth = provider.build_auth(request, saml_config)
+        auth.process_response()
+        errors = auth.get_errors()
+        if errors:
+            error_reason = auth.get_last_error_reason()
+            raise IdentityNotValid(error_reason)
+
+        attributes = auth.get_attributes()
+        nameid = auth.get_nameid()
+
+        email = self.retrieve_email(attributes, nameid, provider.config)
+
+        # Filter users based on the emails provided in the commits
+        user_emails = list(
+            UserEmail.objects.filter(email__iexact=email, is_verified=True).order_by('id')
+        )
+
+        if user_emails:
+            users = list(
+                User.objects.filter(
+                    id__in=set((ue.user_id for ue in user_emails)),
+                    is_active=True,
+                    sentry_orgmember_set__organization_id=organization.id
+                )[0:2]
+            )
+            if users:
+                if len(users) == 1:
+                    user = users[0]
+                    user.backend = settings.AUTHENTICATION_BACKENDS[0]
+                    if login(
+                        request,
+                        user,
+                        after_2fa=request.build_absolute_uri(),
+                        organization_id=organization.id
+                    ):
+                        request.session['saml'] = {
+                            'nameid': nameid,
+                            'nameid_format': auth.get_nameid_format(),
+                            'session_index': auth.get_session_index()
+                        }
+                    return HttpResponseRedirect(get_login_redirect(request))
+                else:
+                    return HttpResponseServerError(
+                        "Found several accounts related with %s on this organization" % email
+                    )
+            else:
+                return HttpResponseServerError(
+                    "The user %s is not related with this organization" % email
+                )
+        else:
+            return HttpResponseServerError(
+                "An user with a verified mail: %s does not exist" % email
+            )
+
+    def retrieve_email(self, attributes, nameid, config):
+        possible_mail = None
+        if nameid and '@' in nameid:
+            possible_mail = nameid
+
+        if attributes and 'attribute_mapping' in config and 'attribute_mapping_email' in config[
+            'attribute_mapping'
+        ]:
+            email_mapping = config['attribute_mapping']['attribute_mapping_email']
+            if email_mapping and email_mapping in attributes:
+                return attributes[email_mapping][0]
+            elif possible_mail:
+                return possible_mail
+            else:
+                raise Exception(
+                    "Email was not provided by the IdP and is required in order to execute the SAML process"
+                )
+        elif possible_mail:
+            return possible_mail
+        else:
+            raise Exception("Email mapping is required in order to execute the SAML process")
+
+    def retrieve_firstname(self, attributes, config):
+        firstname = None
+        if attributes and 'attribute_mapping' in config and 'attribute_mapping_firstname' in config[
+            'attribute_mapping'
+        ]:
+            firstname_mapping = config['attribute_mapping']['attribute_mapping_firstname']
+            if firstname_mapping and firstname_mapping in attributes:
+                firstname = attributes[firstname_mapping][0]
+        return firstname
+
+
+class SAML2MetadataView(AuthView):
+    def dispatch(self, request, organization_slug):
+        provider = get_provider(organization_slug)
+
+        saml_config = provider.build_saml_config(organization_slug)
+        saml_settings = OneLogin_Saml2_Settings(settings=saml_config, sp_validation_only=True)
+        metadata = saml_settings.get_sp_metadata()
+        errors = saml_settings.validate_metadata(metadata)
+
+        if len(errors) == 0:
+            resp = HttpResponse(content=metadata, content_type='text/xml')
+        else:
+            resp = HttpResponseServerError(content=', '.join(errors))
+        return resp
+
+
+class SAML2Provider(Provider):
+    def get_auth_pipeline(self):
+        return [SAML2LoginView()]
+
+    def build_config(self, state):
+        data = {}
+
+        if 'idp' in state.keys():
+            data['idp'] = state['idp']
+
+        if 'contact' in state.keys():
+            data['contact'] = state['contact']
+
+        if data:
+            data['attribute_mapping'] = {
+                'attribute_mapping_email': 'email',
+                'attribute_mapping_firstname': ''
+            }
+        return data
+
+    def build_identity(self, state):
+        # return None   # TODO  If I return None, then a loop after execute the config
+        # happens from organizations/<org>/auth/ to /auth/login/  /<org>/
+        identity = {}
+        if state and 'contact' in state:
+            identity['id'] = state['contact']
+            identity['email'] = state['contact']
+        return identity
+
+    def build_saml_config(self, org_slug):
+        metadata_url = absolute_uri(
+            reverse('sentry-auth-organization-saml-metadata', args=[org_slug])
+        )
+        acs_url = absolute_uri(reverse('sentry-auth-organization-saml-acs', args=[org_slug]))
+
+        saml_config = {}
+        saml_config['strict'] = True
+        saml_config['idp'] = self.extract_parsed_data_from_idp_data(self.config)
+        saml_config['sp'] = {
+            "entityId": metadata_url,
+            "assertionConsumerService": {
+                "url": acs_url,
+                "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
+            }
+        }
+        return saml_config
+
+    def prepare_saml_request(self, request):
+        return {
+            'http_host': request.META['HTTP_HOST'],
+            'script_name': request.META['PATH_INFO'],
+            'server_port': request.META['SERVER_PORT'],
+            'get_data': request.GET.copy(),
+            'post_data': request.POST.copy()
+        }
+
+    def build_auth(self, request, config):
+        req = self.prepare_saml_request(request)
+        return OneLogin_Saml2_Auth(req, config)
+
+    @staticmethod
+    def extract_idp_data_from_form(form):
+        idp_data = {
+            'idp_entityid': form.cleaned_data['idp_entityid'],
+            'idp_sso_url': form.cleaned_data['idp_sso_url'],
+            'idp_x509cert': form.cleaned_data['idp_x509cert']
+        }
+        if form.cleaned_data['idp_slo_url']:
+            idp_data['idp_slo_url'] = form.cleaned_data['idp_slo_url']
+        return idp_data
+
+    @staticmethod
+    def extract_attribute_mapping_from_form(form):
+        mapping_data = {
+            'attribute_mapping_email': form.cleaned_data['attribute_mapping_email'],
+            'attribute_mapping_firstname': form.cleaned_data['attribute_mapping_firstname']
+        }
+        return mapping_data
+
+    @staticmethod
+    def extract_idp_data_from_parsed_data(data):
+        idp_data = {}
+        if 'entityId' in data['idp']:
+            idp_data['idp_entityid'] = data['idp']['entityId']
+        if 'singleSignOnService' in data['idp'] and 'url' in data['idp']['singleSignOnService']:
+            idp_data['idp_sso_url'] = data['idp']['singleSignOnService']['url']
+        if 'singleLogoutService' in data['idp'] and 'url' in data['idp']['singleLogoutService']:
+            idp_data['idp_slo_url'] = data['idp']['singleLogoutService']['url']
+        if 'x509cert' in data['idp']:
+            idp_data['idp_x509cert'] = data['idp']['x509cert']
+        return idp_data
+
+    @staticmethod
+    def extract_parsed_data_from_idp_data(data):
+        parsed_data = {}
+        if 'idp' in data:
+            if 'idp_entityid' in data['idp']:
+                parsed_data['entityId'] = data['idp']['idp_entityid']
+            if 'idp_sso_url' in data['idp']:
+                parsed_data['singleSignOnService'] = {}
+                parsed_data['singleSignOnService']['url'] = data['idp']['idp_sso_url']
+            if 'idp_slo_url' in data['idp']:
+                parsed_data['singleLogoutService'] = {}
+                parsed_data['singleLogoutService']['url'] = data['idp']['idp_slo_url']
+            if 'idp_x509cert' in data['idp']:
+                parsed_data['x509cert'] = data['idp']['idp_x509cert']
+        return parsed_data

BIN
src/sentry/static/sentry/images/auth-providers/onelogin.png


BIN
src/sentry/static/sentry/images/auth-providers/saml2.png


+ 9 - 0
src/sentry/static/sentry/less/auth.less

@@ -12,6 +12,10 @@
   .form-actions {
     margin-top: 20px;
   }
+
+ .form-actions-samlidp {
+    margin: 10px 0;
+  }
 }
 
 section.org-login {
@@ -53,6 +57,11 @@ section.org-login {
     background: url(../images/auth-providers/okta.png);
     background-size: cover;
   }
+
+  &.saml2 {
+    background: url(../images/auth-providers/saml2.png);
+    background-size: cover;
+  }
 }
 
 // Toggle SSO

+ 2 - 2
src/sentry/web/frontend/base.py

@@ -40,8 +40,8 @@ class OrganizationMixin(object):
         # OrganizationBase
         active_organization = getattr(self, '_active_org', None)
         cached_active_org = (
-            active_organization and active_organization[0].slug == organization_slug and
-            active_organization[1] == request.user
+            active_organization and active_organization[0].slug == organization_slug
+            and active_organization[1] == request.user
         )
         if cached_active_org:
             return active_organization[0]

Some files were not shown because too many files changed in this diff