Browse Source

feat(jiraserver) Add OAuth connection process (#10685)

Build out the OAuth1 connection flow required for Jira-Server. Jira uses
a RSA key signed OAuth1 system. I've added the required oauth libraries
to our requirements, however this library was already installed in
getsentry.

I've chosen not to implement `sentry.identity.provider` interface as
there is no requirement for Jira to be used as a single signon provider
and the additional IdentityProviderPipeline could would go unused.

Refs APP-805
Mark Story 6 years ago
parent
commit
03211956b9

+ 1 - 0
requirements-base.txt

@@ -51,6 +51,7 @@ querystring_parser>=1.2.3,<2.0.0
 rb>=1.7.0,<2.0.0
 redis-py-cluster==1.3.4
 redis>=2.10.3,<2.10.6
+requests-oauthlib==0.3.3
 requests[security]>=2.20.0,<2.21.0
 selenium==3.11.0
 semaphore>=0.2.0,<0.3.0

+ 0 - 3
src/sentry/identity/__init__.py

@@ -7,13 +7,11 @@ from .oauth2 import *  # NOQA
 from .slack import *  # NOQA
 from .github import *  # NOQA
 from .github_enterprise import *  # NOQA
-from .jira_server import *  # NOQA
 from .vsts import *  # NOQA
 from .vsts_extension import *  # NOQA
 from .bitbucket import *  # NOQA
 from .gitlab import *  # NOQA
 
-
 default_manager = IdentityManager()
 all = default_manager.all
 get = default_manager.get
@@ -26,7 +24,6 @@ unregister = default_manager.unregister
 register(SlackIdentityProvider)  # NOQA
 register(GitHubIdentityProvider)  # NOQA
 register(GitHubEnterpriseIdentityProvider)  # NOQA
-register(JiraServerIdentityProvider)  # NOQA
 register(VSTSIdentityProvider)  # NOQA
 register(VstsExtensionIdentityProvider)  # NOQA
 register(BitbucketIdentityProvider)  # NOQA

+ 0 - 5
src/sentry/identity/jira_server/__init__.py

@@ -1,5 +0,0 @@
-from __future__ import absolute_import
-
-from sentry.utils.imports import import_submodules
-
-import_submodules(globals(), __name__, __path__)

+ 0 - 17
src/sentry/identity/jira_server/provider.py

@@ -1,17 +0,0 @@
-from __future__ import absolute_import
-
-from sentry.identity.base import Provider
-
-
-class JiraServerIdentityProvider(Provider):
-    name = 'Jira Server'
-    key = 'jira_server'
-
-    def build_identity(self, state):
-        # TODO(lb): This is wrong. Not currently operational.
-        # this should be implemented.
-        return {
-            'type': 'jira_server',
-            'id': state['id'],
-            'name': 'Jira Server',
-        }

+ 106 - 0
src/sentry/integrations/jira_server/client.py

@@ -0,0 +1,106 @@
+from __future__ import absolute_import
+
+from sentry.integrations.client import ApiClient, ApiError
+from requests_oauthlib import OAuth1
+from oauthlib.oauth1 import SIGNATURE_RSA
+from six.moves.urllib.parse import parse_qsl
+
+
+class JiraServerSetupClient(ApiClient):
+    """
+    Client for making requests to JiraServer to follow OAuth1 flow.
+    """
+    request_token_url = u'{}/plugins/servlet/oauth/request-token'
+    access_token_url = u'{}/plugins/servlet/oauth/access-token'
+    authorize_url = u'{}/plugins/servlet/oauth/authorize?oauth_token={}'
+
+    def __init__(self, base_url, consumer_key, private_key, verify_ssl=True):
+        self.base_url = base_url
+        self.consumer_key = consumer_key
+        self.private_key = private_key
+        self.verify_ssl = verify_ssl
+
+    def get_request_token(self):
+        """
+        Step 1 of the oauth flow.
+        Get a request token that we can have the user verify.
+        """
+        url = self.request_token_url.format(self.base_url)
+        resp = self.post(url, allow_text=True)
+        return dict(parse_qsl(resp.text))
+
+    def get_authorize_url(self, request_token):
+        """
+        Step 2 of the oauth flow.
+        Get a URL that the user can verify our request token at.
+        """
+        return self.authorize_url.format(self.base_url, request_token['oauth_token'])
+
+    def get_access_token(self, request_token, verifier):
+        """
+        Step 3 of the oauth flow.
+        Use the verifier and request token from step 1 to get an access token.
+        """
+        if not verifier:
+            raise ApiError('Missing OAuth token verifier')
+        auth = OAuth1(
+            client_key=self.consumer_key,
+            resource_owner_key=request_token['oauth_token'],
+            resource_owner_secret=request_token['oauth_token_secret'],
+            verifier=verifier,
+            rsa_key=self.private_key,
+            signature_method=SIGNATURE_RSA,
+            signature_type='auth_header')
+        url = self.access_token_url.format(self.base_url)
+        resp = self.post(url, auth=auth, allow_text=True)
+        return dict(parse_qsl(resp.text))
+
+    def request(self, *args, **kwargs):
+        """
+        Add OAuth1 RSA signatures.
+        """
+        if 'auth' not in kwargs:
+            kwargs['auth'] = OAuth1(
+                client_key=self.consumer_key,
+                rsa_key=self.private_key,
+                signature_method=SIGNATURE_RSA,
+                signature_type='auth_header')
+        return self._request(*args, **kwargs)
+
+
+class JiraServerClient(ApiClient):
+    """
+    Client for making authenticated requests to JiraServer
+    """
+
+    def __init__(self, installation):
+        self.installation = installation
+        verify_ssl = self.metadata['verify_ssl']
+        super(JiraServerClient, self).__init__(verify_ssl)
+
+    @property
+    def identity(self):
+        return self.installation.default_identity
+
+    @property
+    def metadata(self):
+        return self.installation.model.metadata
+
+    def request(self, *args, **kwargs):
+        if 'auth' not in kwargs:
+            kwargs['auth'] = OAuth1(
+                client_key=self.metadat['consumer_key'],
+                rsa_key=self.metadata['private_key'],
+                resource_owner_key=self.metadata['access_token'],
+                resource_owner_secret=self.metadata['access_token_secret'],
+                signature_method=SIGNATURE_RSA,
+                signature_type='auth_header')
+        return self._request(*args, **kwargs)
+
+    def get_valid_statuses(self):
+        # TODO Implement this.
+        return []
+
+    def get_projects_list(self):
+        # TODO Implement this
+        return []

+ 93 - 33
src/sentry/integrations/jira_server/integration.py

@@ -4,15 +4,21 @@ import logging
 
 from django import forms
 from django.utils.translation import ugettext as _
+from django.views.decorators.csrf import csrf_exempt
+from six.moves.urllib.parse import urlparse
 
-from sentry.identity.pipeline import IdentityProviderPipeline
 from sentry.integrations import (
-    IntegrationFeatures, IntegrationProvider, IntegrationMetadata, FeatureDescription,
+    IntegrationFeatures,
+    IntegrationProvider,
+    IntegrationMetadata,
+    FeatureDescription,
 )
+from sentry.integrations.client import ApiError
 from sentry.integrations.jira import JiraIntegration
-from sentry.pipeline import NestedPipelineView, PipelineView
-from sentry.utils.http import absolute_uri
+from sentry.pipeline import PipelineView
 from sentry.web.helpers import render_to_response
+from .client import JiraServerClient, JiraServerSetupClient
+
 
 logger = logging.getLogger('sentry.integrations.jira_server')
 
@@ -74,7 +80,7 @@ class InstallationForm(forms.Form):
         required=False,
         initial=True
     )
-    client_id = forms.CharField(
+    consumer_key = forms.CharField(
         label=_('Jira Consumer Key'),
         widget=forms.TextInput(
             attrs={'placeholder': _(
@@ -132,8 +138,70 @@ class InstallationConfigView(PipelineView):
         )
 
 
+class OAuthLoginView(PipelineView):
+    """
+    Start the OAuth dance by creating a request token
+    and redirecting the user to approve it.
+    """
+    @csrf_exempt
+    def dispatch(self, request, pipeline):
+        if 'oauth_token' in request.GET:
+            return pipeline.next_step()
+
+        config = pipeline.fetch_state('installation_data')
+        client = JiraServerSetupClient(
+            config.get('url'),
+            config.get('consumer_key'),
+            config.get('private_key'),
+            config.get('verify_ssl'),
+        )
+        try:
+            request_token = client.get_request_token()
+            pipeline.bind_state('request_token', request_token)
+            authorize_url = client.get_authorize_url(request_token)
+
+            return self.redirect(authorize_url)
+        except ApiError as error:
+            logger.info('identity.jira-server.request-token', extra={'error': error})
+            return pipeline.error('Could not fetch a request token from Jira')
+
+
+class OAuthCallbackView(PipelineView):
+    """
+    Complete the OAuth dance by exchanging our request token
+    into an access token.
+    """
+    @csrf_exempt
+    def dispatch(self, request, pipeline):
+        config = pipeline.fetch_state('installation_data')
+        client = JiraServerSetupClient(
+            config.get('url'),
+            config.get('consumer_key'),
+            config.get('private_key'),
+            config.get('verify_ssl'),
+        )
+
+        try:
+            access_token = client.get_access_token(
+                pipeline.fetch_state('request_token'),
+                request.GET['oauth_token']
+            )
+            pipeline.bind_state('access_token', access_token)
+
+            return pipeline.next_step()
+        except ApiError as error:
+            logger.info('identity.jira-server.access-token', extra={'error': error})
+            return pipeline.error('Could not fetch an access token from Jira')
+
+
 class JiraServerIntegration(JiraIntegration):
-    pass
+    default_identity = None
+
+    def get_client(self):
+        if self.default_identity is None:
+            self.default_identity = self.get_default_identity()
+
+        return JiraServerClient(self)
 
 
 class JiraServerIntegrationProvider(IntegrationProvider):
@@ -156,43 +224,35 @@ class JiraServerIntegrationProvider(IntegrationProvider):
         'height': 1000,
     }
 
-    def _make_identity_pipeline_view(self):
-        """
-        Make the nested identity provider view.
-
-        It is important that this view is not constructed until we reach this step and the
-        ``installation_data`` is available in the pipeline state. This
-        method should be late bound into the pipeline views.
-        """
-        identity_pipeline_config = dict(
-            redirect_url=absolute_uri('/extensions/jira_server/setup/'),
-            **self.pipeline.fetch_state('installation_data')
-        )
-
-        return NestedPipelineView(
-            bind_key='identity',
-            provider_key='jira_server',
-            pipeline_cls=IdentityProviderPipeline,
-            config=identity_pipeline_config,
-        )
-
     def get_pipeline_views(self):
         return [
             InstallationGuideView(),
             InstallationConfigView(),
-            # lambda: self._make_identity_pipeline_view()
+            OAuthLoginView(),
+            OAuthCallbackView(),
         ]
 
     def build_integration(self, state):
-        # TODO complete OAuth
-        # TODO(lb): This is wrong. Not currently operational.
-        # this should be implemented.
-        user = state['identity']['data']
+        install = state['installation_data']
+        access_token = state['access_token']
+        hostname = urlparse(install['url']).netloc
         return {
+            'name': install['consumer_key'],
             'provider': 'jira_server',
-            'external_id': '%s:%s' % (state['base_url'], state['id']),
+            'external_id': '%s:%s' % (hostname, install['consumer_key']),
+            'metadata': {
+                'base_url': install['url'],
+                'verify_ssl': install['verify_ssl'],
+            },
             'user_identity': {
                 'type': 'jira_server',
-                'external_id': '%s:%s' % (state['base_url'], user['id'])
+                'external_id': '%s:%s' % (hostname, install['consumer_key']),
+                'scopes': (),
+                'data': {
+                    'consumer_key': install['consumer_key'],
+                    'private_key': install['private_key'],
+                    'access_token': access_token['oauth_token'],
+                    'access_token_secret': access_token['oauth_token_secret'],
+                }
             }
         }

+ 211 - 27
tests/sentry/integrations/jira_server/test_integration.py

@@ -1,38 +1,37 @@
 from __future__ import absolute_import
 
 from sentry.integrations.jira_server import JiraServerIntegrationProvider
-from sentry.identity.jira_server import JiraServerIdentityProvider
+from sentry.models import (
+    Identity,
+    IdentityProvider,
+    Integration,
+    OrganizationIntegration
+)
 from sentry.testutils import IntegrationTestCase
+import responses
+
+PRIVATE_KEY = '''
+-----BEGIN RSA PRIVATE KEY-----
+MIICWwIBAAKBgQC1cd9t8sA03awggLiX2gjZxyvOVUPJksLly1E662tttTeR3Wm9
+eo6onNeI8HRD+O4wubUp4h4Chc7DtLDmFEPhUZ8Qkwztiifm99Xo3s0nUq4Pygp5
+AU09KXTEPbzHLh1dnXLcxVLmGDE4drh0NWmYsd/Zp7XNIZq2TRQQ3NTdVQIDAQAB
+AoGAFwMyS0eWiR30TssEnn3Q0Y4pSCoYRuCOR4bZ7pcdMPTi72UdnCKHJWt/Cqc0
+l8piq1tiVsWO+NLvvnKUXRoE4cAyrGrpf1F0uP5zYW71SQALc9wwsjDzuj7BZEuK
+fg35JSceLHWE1WtzPDX5Xg20YPnMrA/xe/RwuPjuBH0wSqECQQDizzmKdKCq0ejy
+3OxEto5knqpSEgRcOk0HDsdgjwkwiZJOj5ECV2FKpNHuu2thGy/aDJyLlmUso8j0
+OpvLAzOvAkEAzMwAgGexTxKm8hy3ilvVn9EvhSKjaIakqY4ONK9LZ4zMiDHI0H6C
+FXlwWX7CJM0YVFMubj8SB8rnIuvFDEBMOwJABHtRyMGbNyTktH/XD1iIIcbc2LhQ
+a74fLYeGOws4hEQDpxfBJsmxO3dcSppbedS+slFTepKjNymZW/IYh/9tMwJAEL5E
+9DqGBn7x4y1x2//yESTbC7lvPqZzY+FXS/tg4NBkEGZxkoolPHg3NTnlyXhzGsHK
+M/04DicKipJYA85l7QJAJ3u67qZXecM/oWTtJToBDuyKGHfdY1564+RbyDEjJJRb
+vz4O/8FQQ1sGjdEBMMrRBCHEG8o3/XDTrB97t45TeA==
+-----END RSA PRIVATE KEY-----
+'''
 
 
 class JiraServerIntegrationTest(IntegrationTestCase):
     provider = JiraServerIntegrationProvider
 
-    def test_temporary_identity_provider(self):
-        provider = JiraServerIdentityProvider()
-        state = {'id': 'identity-id'}
-        assert provider.build_identity(state) == {
-            'type': 'jira_server',
-            'id': state['id'],
-            'name': 'Jira Server',
-        }
-
-    def test_temporary_integration_provider(self):
-        provider = JiraServerIntegrationProvider()
-        state = {
-            'identity': {'data': {'id': 'user-id'}},
-            'base_url': 'https://jira-server.com/',
-            'id': 'integration-id',
-        }
-        assert provider.build_integration(state) == {
-            'provider': 'jira_server',
-            'external_id': '%s:%s' % (state['base_url'], state['id']),
-            'user_identity': {
-                'type': 'jira_server',
-                'external_id': '%s:%s' % (state['base_url'], state['identity']['data']['id'])
-            }
-        }
-
     def test_setup_guide(self):
         resp = self.client.get(self.init_path)
         assert resp.status_code == 200
@@ -41,7 +40,192 @@ class JiraServerIntegrationTest(IntegrationTestCase):
         self.assertContains(resp, 'Next</a>')
 
     def test_config_view(self):
-        resp = self.client.get(self.init_path + '?completed_guide')
+        resp = self.client.get(self.init_path)
+        assert resp.status_code == 200
+
+        resp = self.client.get(self.setup_path + '?completed_guide')
         assert resp.status_code == 200
         self.assertContains(resp, 'Step 2:')
         self.assertContains(resp, 'Submit</button>')
+
+    @responses.activate
+    def test_authentication_request_token_fails(self):
+        responses.add(
+            responses.POST,
+            'https://jira.example.com/plugins/servlet/oauth/request-token',
+            status=503)
+
+        # Start pipeline and go to setup page.
+        self.client.get(self.init_path)
+        self.client.get(self.setup_path + '?completed_guide')
+
+        # Submit credentials
+        data = {
+            'url': 'https://jira.example.com/',
+            'verify_ssl': False,
+            'consumer_key': 'sentry-bot',
+            'private_key': PRIVATE_KEY
+        }
+        resp = self.client.post(self.setup_path, data=data)
+        assert resp.status_code == 200
+        self.assertContains(resp, 'Setup Error')
+        self.assertContains(resp, 'request token from Jira')
+
+    @responses.activate
+    def test_authentication_request_token_redirect(self):
+        responses.add(
+            responses.POST,
+            'https://jira.example.com/plugins/servlet/oauth/request-token',
+            status=200,
+            content_type='text/plain',
+            body='oauth_token=abc123&oauth_token_secret=def456')
+
+        # Start pipeline
+        self.client.get(self.init_path)
+        self.client.get(self.setup_path + '?completed_guide')
+
+        # Submit credentials
+        data = {
+            'url': 'https://jira.example.com/',
+            'verify_ssl': False,
+            'consumer_key': 'sentry-bot',
+            'private_key': PRIVATE_KEY
+        }
+        resp = self.client.post(self.setup_path, data=data)
+        assert resp.status_code == 302
+        redirect = 'https://jira.example.com/plugins/servlet/oauth/authorize?oauth_token=abc123'
+        assert redirect == resp['Location']
+
+    @responses.activate
+    def test_authentication_access_token_failure(self):
+        responses.add(
+            responses.POST,
+            'https://jira.example.com/plugins/servlet/oauth/request-token',
+            status=200,
+            content_type='text/plain',
+            body='oauth_token=abc123&oauth_token_secret=def456')
+        responses.add(
+            responses.POST,
+            'https://jira.example.com/plugins/servlet/oauth/access-token',
+            status=500,
+            content_type='text/plain',
+            body='<html>it broke</html>')
+
+        # Get guide page
+        resp = self.client.get(self.init_path)
+        assert resp.status_code == 200
+
+        # Get config page
+        resp = self.client.get(self.setup_path + '?completed_guide')
+        assert resp.status_code == 200
+
+        # Submit credentials
+        data = {
+            'url': 'https://jira.example.com/',
+            'verify_ssl': False,
+            'consumer_key': 'sentry-bot',
+            'private_key': PRIVATE_KEY
+        }
+        resp = self.client.post(self.setup_path, data=data)
+        assert resp.status_code == 302
+        assert resp['Location']
+
+        resp = self.client.get(self.setup_path + '?oauth_token=xyz789')
+        assert resp.status_code == 200
+        self.assertContains(resp, 'Setup Error')
+        self.assertContains(resp, 'access token from Jira')
+
+    @responses.activate
+    def test_authentication_verifier_expired(self):
+        responses.add(
+            responses.POST,
+            'https://jira.example.com/plugins/servlet/oauth/request-token',
+            status=200,
+            content_type='text/plain',
+            body='oauth_token=abc123&oauth_token_secret=def456')
+        responses.add(
+            responses.POST,
+            'https://jira.example.com/plugins/servlet/oauth/access-token',
+            status=404,
+            content_type='text/plain',
+            body='oauth_error=token+expired')
+
+        # Get guide page
+        self.client.get(self.init_path)
+
+        # Get config page
+        self.client.get(self.setup_path + '?completed_guide')
+
+        # Submit credentials
+        data = {
+            'url': 'https://jira.example.com/',
+            'verify_ssl': False,
+            'consumer_key': 'sentry-bot',
+            'private_key': PRIVATE_KEY
+        }
+        self.client.post(self.setup_path, data=data)
+
+        # Try getting the token but it has expired for some reason,
+        # perhaps a stale reload/history navigate.
+        resp = self.client.get(self.setup_path + '?oauth_token=xyz789')
+        assert resp.status_code == 200
+        self.assertContains(resp, 'Setup Error')
+        self.assertContains(resp, 'access token from Jira')
+
+    @responses.activate
+    def test_authentication_success(self):
+        responses.add(
+            responses.POST,
+            'https://jira.example.com/plugins/servlet/oauth/request-token',
+            status=200,
+            content_type='text/plain',
+            body='oauth_token=abc123&oauth_token_secret=def456')
+        responses.add(
+            responses.POST,
+            'https://jira.example.com/plugins/servlet/oauth/access-token',
+            status=200,
+            content_type='text/plain',
+            body='oauth_token=valid-token&oauth_token_secret=valid-secret')
+
+        # Get guide page
+        resp = self.client.get(self.init_path)
+        assert resp.status_code == 200
+
+        # Get config page
+        resp = self.client.get(self.setup_path + '?completed_guide')
+        assert resp.status_code == 200
+
+        # Submit credentials
+        data = {
+            'url': 'https://jira.example.com/',
+            'verify_ssl': False,
+            'consumer_key': 'sentry-bot',
+            'private_key': PRIVATE_KEY
+        }
+        resp = self.client.post(self.setup_path, data=data)
+        assert resp.status_code == 302
+        assert resp['Location']
+
+        resp = self.client.get(self.setup_path + '?oauth_token=xyz789')
+        assert resp.status_code == 200
+
+        integration = Integration.objects.get()
+        assert integration.name == 'sentry-bot'
+        assert integration.metadata['base_url'] == 'https://jira.example.com'
+        assert integration.metadata['verify_ssl'] is False
+
+        org_integration = OrganizationIntegration.objects.get(
+            integration=integration,
+            organization=self.organization)
+        assert org_integration.config == {}
+
+        idp = IdentityProvider.objects.get(type='jira_server')
+        identity = Identity.objects.get(
+            idp=idp,
+            user=self.user,
+            external_id='jira.example.com:sentry-bot',
+        )
+        assert identity.data['consumer_key'] == 'sentry-bot'
+        assert identity.data['access_token'] == 'valid-token'
+        assert identity.data['access_token_secret'] == 'valid-secret'
+        assert identity.data['private_key'] == PRIVATE_KEY