Browse Source

feat(auth): adds saml2 providers (#16254)

Stephen Cefali 5 years ago
parent
commit
453929759a

+ 5 - 0
setup.py

@@ -107,7 +107,12 @@ setup(
         "console_scripts": ["sentry = sentry.runner:main"],
         "sentry.apps": [
             # TODO: This can be removed once the getsentry tests no longer check for this app
+            "auth_auth0 = sentry.auth.providers.saml2.auth0",
             "auth_github = sentry.auth.providers.github",
+            "auth_okta = sentry.auth.providers.saml2.okta",
+            "auth_onelogin = sentry.auth.providers.saml2.onelogin",
+            "auth_rippling = sentry.auth.providers.saml2.rippling",
+            "auth_saml2 = sentry.auth.providers.saml2.generic",
             "jira_ac = sentry_plugins.jira_ac",
             "jira = sentry_plugins.jira",
             "freight = sentry_plugins.freight",

+ 1 - 0
src/sentry/auth/providers/saml2/__init__.py

@@ -0,0 +1 @@
+from __future__ import absolute_import

+ 3 - 0
src/sentry/auth/providers/saml2/auth0/__init__.py

@@ -0,0 +1,3 @@
+from __future__ import absolute_import
+
+default_app_config = "sentry.auth.providers.saml2.auth0.apps.Config"

+ 14 - 0
src/sentry/auth/providers/saml2/auth0/apps.py

@@ -0,0 +1,14 @@
+from __future__ import absolute_import
+
+from django.apps import AppConfig
+
+
+class Config(AppConfig):
+    name = "sentry.auth.providers.saml2.auth0"
+
+    def ready(self):
+        from sentry.auth import register
+
+        from .provider import Auth0SAML2Provider
+
+        register("auth0", Auth0SAML2Provider)

+ 24 - 0
src/sentry/auth/providers/saml2/auth0/provider.py

@@ -0,0 +1,24 @@
+from __future__ import absolute_import, print_function
+
+from sentry.auth.providers.saml2.provider import SAML2Provider, Attributes
+from sentry.auth.providers.saml2.views import make_simple_setup
+from sentry.auth.providers.saml2.forms import URLMetadataForm
+
+
+SelectIdP = make_simple_setup(URLMetadataForm, "sentry_auth_auth0/select-idp.html")
+
+
+class Auth0SAML2Provider(SAML2Provider):
+    name = "Auth0"
+
+    def get_saml_setup_pipeline(self):
+        return [SelectIdP()]
+
+    def attribute_mapping(self):
+        return {
+            Attributes.IDENTIFIER: "user_id",
+            Attributes.USER_EMAIL: "email",
+            # Auth0 does not provider first / last names
+            Attributes.FIRST_NAME: "name",
+            Attributes.LAST_NAME: None,
+        }

+ 30 - 0
src/sentry/auth/providers/saml2/auth0/templates/sentry_auth_auth0/select-idp.html

@@ -0,0 +1,30 @@
+{% extends "sentry/bases/modal.html" %}
+{% load sentry_assets %}
+{% load i18n %}
+
+{% block wrapperclass %}narrow auth{% endblock %}
+{% block modal_header_signout %}{% endblock %}
+
+{% block title %}{% trans "Register Auth0" %} | {{ block.super }}{% endblock %}
+
+{% block main %}
+<h3>{% trans "Register Auth0" %}</h3>
+
+<p>
+  As part of Auth0 SSO provisioning, you must to provide the Auth0 identity
+  provider Metadata URL to Sentry.
+</p>
+
+<form method="post" class="form-stacked">
+  {% csrf_token %}
+  {% include "sentry/partial/form_base.html" %}
+  <input type="hidden" name="provider" value="auth0" />
+
+  <fieldset class="form-actions">
+    <button type="submit" class="btn btn-primary" name="action_save">{% trans "Continue" %}</button>
+    <a class="pull-right" style="margin-top: 7px" href="https://docs.sentry.io/learn/sso/#auth0">
+      Need help finding your Metadata URL?
+    </a>
+  </fieldset>
+</form>
+{% endblock %}

+ 122 - 0
src/sentry/auth/providers/saml2/forms.py

@@ -0,0 +1,122 @@
+from __future__ import absolute_import
+
+from django import forms
+from django.utils.encoding import force_text
+from django.utils.translation import ugettext_lazy as _
+from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
+
+from django.forms.utils import ErrorList
+
+from sentry.http import safe_urlopen
+
+
+def extract_idp_data_from_parsed_data(data):
+    """
+    Transform data returned by the OneLogin_Saml2_IdPMetadataParser into the
+    expected IdP dict shape.
+    """
+    idp = data.get("idp", {})
+
+    # In some scenarios the IDP sticks the x509cert in the x509certMulti
+    # parameter
+    cert = idp.get("x509cert", idp.get("x509certMulti", {}).get("signing", [None])[0])
+
+    return {
+        "entity_id": idp.get("entityId"),
+        "sso_url": idp.get("singleSignOnService", {}).get("url"),
+        "slo_url": idp.get("singleLogoutService", {}).get("url"),
+        "x509cert": cert,
+    }
+
+
+def process_url(form):
+    url = form.cleaned_data["metadata_url"]
+    response = safe_urlopen(url)
+    data = OneLogin_Saml2_IdPMetadataParser.parse(response.content)
+    return extract_idp_data_from_parsed_data(data)
+
+
+def process_xml(form):
+    # cast unicode xml to byte string so lxml won't complain when trying to
+    # parse a xml document with a type declaration.
+    xml = form.cleaned_data["metadata_xml"].encode("utf8")
+    data = OneLogin_Saml2_IdPMetadataParser.parse(xml)
+    return extract_idp_data_from_parsed_data(data)
+
+
+class URLMetadataForm(forms.Form):
+    metadata_url = forms.URLField(label="Metadata URL")
+    processor = process_url
+
+
+class XMLMetadataForm(forms.Form):
+    metadata_xml = forms.CharField(label="Metadata XML", widget=forms.Textarea)
+    processor = process_xml
+
+
+class SAMLForm(forms.Form):
+    entity_id = forms.CharField(label="Entity ID")
+    sso_url = forms.URLField(label="Single Sign On URL")
+    slo_url = forms.URLField(label="Single Log Out URL", required=False)
+    x509cert = forms.CharField(label="x509 public certificate", widget=forms.Textarea)
+    processor = lambda d: d.cleaned_data
+
+
+def process_metadata(form_cls, request, helper):
+    form = form_cls()
+
+    if "action_save" not in request.POST:
+        return form
+
+    form = form_cls(request.POST)
+
+    if not form.is_valid():
+        return form
+
+    try:
+        data = form_cls.processor(form)
+    except Exception:
+        errors = form._errors.setdefault("__all__", ErrorList())
+        errors.append("Failed to parse provided SAML2 metadata")
+        return form
+
+    saml_form = SAMLForm(data)
+    if not saml_form.is_valid():
+        field_errors = [
+            "%s: %s" % (k, ", ".join([force_text(i) for i in v]))
+            for k, v in saml_form.errors.items()
+        ]
+        error_list = ", ".join(field_errors)
+
+        errors = form._errors.setdefault("__all__", ErrorList())
+        errors.append(u"Invalid metadata: {}".format(error_list))
+        return form
+
+    helper.bind_state("idp", data)
+
+    # Data is bound, do not respond with a form to signal the nexts steps
+    return None
+
+
+class AttributeMappingForm(forms.Form):
+    # NOTE: These fields explicitly map to the sentry.auth.saml2.Attributes keys
+    identifier = forms.CharField(
+        label="IdP User ID",
+        widget=forms.TextInput(attrs={"placeholder": "eg. user.uniqueID"}),
+        help_text=_(
+            "The IdPs unique ID attribute key for the user. This is "
+            "what Sentry will used to identify the users identity from "
+            "the identity provider."
+        ),
+    )
+    user_email = forms.CharField(
+        label="User Email",
+        widget=forms.TextInput(attrs={"placeholder": "eg. user.email"}),
+        help_text=_(
+            "The IdPs email address attribute key for the "
+            "user. Upon initial linking this will be used to identify "
+            "the user in Sentry."
+        ),
+    )
+    first_name = forms.CharField(label="First Name", required=False)
+    last_name = forms.CharField(label="Last Name", required=False)

+ 3 - 0
src/sentry/auth/providers/saml2/generic/__init__.py

@@ -0,0 +1,3 @@
+from __future__ import absolute_import
+
+default_app_config = "sentry.auth.providers.saml2.generic.apps.Config"

+ 14 - 0
src/sentry/auth/providers/saml2/generic/apps.py

@@ -0,0 +1,14 @@
+from __future__ import absolute_import
+
+from django.apps import AppConfig
+
+
+class Config(AppConfig):
+    name = "sentry.auth.providers.saml2.generic"
+
+    def ready(self):
+        from sentry.auth import register
+
+        from .provider import GenericSAML2Provider
+
+        register("saml2", GenericSAML2Provider)

+ 15 - 0
src/sentry/auth/providers/saml2/generic/provider.py

@@ -0,0 +1,15 @@
+from __future__ import absolute_import, print_function
+
+from sentry.auth.providers.saml2.provider import SAML2Provider
+
+from .views import SAML2ConfigureView, SelectIdP, MapAttributes
+
+
+class GenericSAML2Provider(SAML2Provider):
+    name = "SAML2"
+
+    def get_configure_view(self):
+        return SAML2ConfigureView.as_view()
+
+    def get_saml_setup_pipeline(self):
+        return [SelectIdP(), MapAttributes()]

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