From aa8eaf29ed57ded261e2431d4c7d975fd77a1fe6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 29 Jan 2019 10:09:57 -0800 Subject: [PATCH] feat(auth): Pull in sentry-google-auth - renames app from sentry_auth_google to google - adds support for options-based configuration --- src/sentry/auth/providers/google/__init__.py | 16 +++ src/sentry/auth/providers/google/constants.py | 18 +++ src/sentry/auth/providers/google/provider.py | 116 ++++++++++++++++++ .../sentry_auth_google/configure.html | 3 + src/sentry/auth/providers/google/utils.py | 8 ++ src/sentry/auth/providers/google/views.py | 81 ++++++++++++ src/sentry/auth/providers/oauth2.py | 16 ++- src/sentry/conf/server.py | 2 +- src/sentry/runner/initializer.py | 17 +-- .../auth/providers/google/test_provider.py | 73 +++++++++++ 10 files changed, 337 insertions(+), 13 deletions(-) create mode 100644 src/sentry/auth/providers/google/__init__.py create mode 100644 src/sentry/auth/providers/google/constants.py create mode 100644 src/sentry/auth/providers/google/provider.py create mode 100644 src/sentry/auth/providers/google/templates/sentry_auth_google/configure.html create mode 100644 src/sentry/auth/providers/google/utils.py create mode 100644 src/sentry/auth/providers/google/views.py create mode 100644 tests/sentry/auth/providers/google/test_provider.py diff --git a/src/sentry/auth/providers/google/__init__.py b/src/sentry/auth/providers/google/__init__.py new file mode 100644 index 00000000000000..a1fdc41de1fe62 --- /dev/null +++ b/src/sentry/auth/providers/google/__init__.py @@ -0,0 +1,16 @@ +from __future__ import absolute_import + +from sentry import auth, options + +from .provider import GoogleOAuth2Provider + +auth.register('google', GoogleOAuth2Provider) + +options.register( + 'auth-google.client-id', + flags=options.FLAG_ALLOW_EMPTY | options.FLAG_PRIORITIZE_DISK, +) +options.register( + 'auth-google.client-secret', + flags=options.FLAG_ALLOW_EMPTY | options.FLAG_PRIORITIZE_DISK, +) diff --git a/src/sentry/auth/providers/google/constants.py b/src/sentry/auth/providers/google/constants.py new file mode 100644 index 00000000000000..7135112d5bceaa --- /dev/null +++ b/src/sentry/auth/providers/google/constants.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import, print_function + +from django.conf import settings + + +AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/auth' + +ACCESS_TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token' + +ERR_INVALID_DOMAIN = 'The domain for your Google account (%s) is not allowed to authenticate with this provider.' + +ERR_INVALID_RESPONSE = 'Unable to fetch user information from Google. Please check the log.' + +SCOPE = 'email' + +DOMAIN_BLOCKLIST = frozenset(getattr(settings, 'GOOGLE_DOMAIN_BLOCKLIST', ['gmail.com']) or []) + +DATA_VERSION = '1' diff --git a/src/sentry/auth/providers/google/provider.py b/src/sentry/auth/providers/google/provider.py new file mode 100644 index 00000000000000..b4cac52b5c38f2 --- /dev/null +++ b/src/sentry/auth/providers/google/provider.py @@ -0,0 +1,116 @@ +from __future__ import absolute_import, print_function + +from sentry import options +from sentry.auth.provider import MigratingIdentityId +from sentry.auth.providers.oauth2 import ( + OAuth2Callback, OAuth2Provider, OAuth2Login +) + +from .constants import ( + AUTHORIZE_URL, ACCESS_TOKEN_URL, DATA_VERSION, SCOPE +) +from .views import FetchUser, GoogleConfigureView + + +class GoogleOAuth2Login(OAuth2Login): + authorize_url = AUTHORIZE_URL + scope = SCOPE + + def __init__(self, client_id, domains=None): + self.domains = domains + super(GoogleOAuth2Login, self).__init__(client_id=client_id) + + def get_authorize_params(self, state, redirect_uri): + params = super(GoogleOAuth2Login, self).get_authorize_params( + state, redirect_uri + ) + # TODO(dcramer): ideally we could look at the current resulting state + # when an existing auth happens, and if they're missing a refresh_token + # we should re-prompt them a second time with ``approval_prompt=force`` + params['approval_prompt'] = 'force' + params['access_type'] = 'offline' + return params + + +class GoogleOAuth2Provider(OAuth2Provider): + name = 'Google' + + def __init__(self, domain=None, domains=None, version=None, **config): + if domain: + if domains: + domains.append(domain) + else: + domains = [domain] + self.domains = domains + # if a domain is not configured this is part of the setup pipeline + # this is a bit complex in Sentry's SSO implementation as we don't + # provide a great way to get initial state for new setup pipelines + # vs missing state in case of migrations. + if domains is None: + version = DATA_VERSION + else: + version = None + self.version = version + super(GoogleOAuth2Provider, self).__init__(**config) + + def get_client_id(self): + return options.get('auth-google.client-id') + + def get_client_secret(self): + return options.get('auth-google.client-secret') + + def get_configure_view(self): + return GoogleConfigureView.as_view() + + def get_auth_pipeline(self): + return [ + GoogleOAuth2Login(domains=self.domains, client_id=self.get_client_id()), + OAuth2Callback( + access_token_url=ACCESS_TOKEN_URL, + client_id=self.get_client_id(), + client_secret=self.get_client_secret(), + ), + FetchUser( + domains=self.domains, + version=self.version, + ), + ] + + def get_refresh_token_url(self): + return ACCESS_TOKEN_URL + + def build_config(self, state): + return { + 'domains': [state['domain']], + 'version': DATA_VERSION, + } + + def build_identity(self, state): + # https://developers.google.com/identity/protocols/OpenIDConnect#server-flow + # data.user => { + # "iss":"accounts.google.com", + # "at_hash":"HK6E_P6Dh8Y93mRNtsDB1Q", + # "email_verified":"true", + # "sub":"10769150350006150715113082367", + # "azp":"1234987819200.apps.googleusercontent.com", + # "email":"jsmith@example.com", + # "aud":"1234987819200.apps.googleusercontent.com", + # "iat":1353601026, + # "exp":1353604926, + # "hd":"example.com" + # } + data = state['data'] + user_data = state['user'] + + # XXX(epurkhiser): We initially were using the email as the id key. + # This caused account dupes on domain changes. Migrate to the + # account-unique sub key. + user_id = MigratingIdentityId(id=user_data['sub'], legacy_id=user_data['email']) + + return { + 'id': user_id, + 'email': user_data['email'], + 'name': user_data['email'], + 'data': self.get_oauth_data(data), + 'email_verified': user_data['email_verified'], + } diff --git a/src/sentry/auth/providers/google/templates/sentry_auth_google/configure.html b/src/sentry/auth/providers/google/templates/sentry_auth_google/configure.html new file mode 100644 index 00000000000000..dc3e7e40378fe2 --- /dev/null +++ b/src/sentry/auth/providers/google/templates/sentry_auth_google/configure.html @@ -0,0 +1,3 @@ +

Domain

+ +

Users will be allowed to authenticate if they have an account under the Google Apps domain {{ domains|join:" or " }}.

diff --git a/src/sentry/auth/providers/google/utils.py b/src/sentry/auth/providers/google/utils.py new file mode 100644 index 00000000000000..73740b3b5666bb --- /dev/null +++ b/src/sentry/auth/providers/google/utils.py @@ -0,0 +1,8 @@ +from __future__ import absolute_import, print_function + +import base64 + + +def urlsafe_b64decode(b64string): + padded = b64string + b'=' * (4 - len(b64string) % 4) + return base64.urlsafe_b64decode(padded) diff --git a/src/sentry/auth/providers/google/views.py b/src/sentry/auth/providers/google/views.py new file mode 100644 index 00000000000000..d1384eba14ada3 --- /dev/null +++ b/src/sentry/auth/providers/google/views.py @@ -0,0 +1,81 @@ +from __future__ import absolute_import, print_function + +import logging + +from sentry.auth.view import AuthView, ConfigureView +from sentry.utils import json + +from .constants import ( + DOMAIN_BLOCKLIST, ERR_INVALID_DOMAIN, ERR_INVALID_RESPONSE, +) +from .utils import urlsafe_b64decode + +logger = logging.getLogger('sentry.auth.google') + + +class FetchUser(AuthView): + def __init__(self, domains, version, *args, **kwargs): + self.domains = domains + self.version = version + super(FetchUser, self).__init__(*args, **kwargs) + + def dispatch(self, request, helper): + data = helper.fetch_state('data') + + try: + id_token = data['id_token'] + except KeyError: + logger.error('Missing id_token in OAuth response: %s' % data) + return helper.error(ERR_INVALID_RESPONSE) + + try: + _, payload, _ = map(urlsafe_b64decode, id_token.split('.', 2)) + except Exception as exc: + logger.error(u'Unable to decode id_token: %s' % exc, exc_info=True) + return helper.error(ERR_INVALID_RESPONSE) + + try: + payload = json.loads(payload) + except Exception as exc: + logger.error(u'Unable to decode id_token payload: %s' % exc, exc_info=True) + return helper.error(ERR_INVALID_RESPONSE) + + if not payload.get('email'): + logger.error('Missing email in id_token payload: %s' % id_token) + return helper.error(ERR_INVALID_RESPONSE) + + # support legacy style domains with pure domain regexp + if self.version is None: + domain = extract_domain(payload['email']) + else: + domain = payload.get('hd') + + if domain is None: + return helper.error(ERR_INVALID_DOMAIN % (domain,)) + + if domain in DOMAIN_BLOCKLIST: + return helper.error(ERR_INVALID_DOMAIN % (domain,)) + + if self.domains and domain not in self.domains: + return helper.error(ERR_INVALID_DOMAIN % (domain,)) + + helper.bind_state('domain', domain) + helper.bind_state('user', payload) + + return helper.next_step() + + +class GoogleConfigureView(ConfigureView): + def dispatch(self, request, organization, auth_provider): + config = auth_provider.config + if config.get('domain'): + domains = [config['domain']] + else: + domains = config.get('domains') + return self.render('sentry_auth_google/configure.html', { + 'domains': domains or [], + }) + + +def extract_domain(email): + return email.rsplit('@', 1)[-1] diff --git a/src/sentry/auth/providers/oauth2.py b/src/sentry/auth/providers/oauth2.py index 906983f9d25600..7003c4a90f6299 100644 --- a/src/sentry/auth/providers/oauth2.py +++ b/src/sentry/auth/providers/oauth2.py @@ -128,14 +128,20 @@ class OAuth2Provider(Provider): client_id = None client_secret = None + def get_client_id(self): + return self.client_id + + def get_client_secret(self): + return self.client_secret + def get_auth_pipeline(self): return [ OAuth2Login( - client_id=self.client_id, + client_id=self.get_client_id(), ), OAuth2Callback( - client_id=self.client_id, - client_secret=self.client_secret, + client_id=self.get_client_id(), + client_secret=self.get_client_secret(), ), ] @@ -144,8 +150,8 @@ def get_refresh_token_url(self): def get_refresh_token_params(self, refresh_token): return { - "client_id": self.client_id, - "client_secret": self.client_secret, + "client_id": self.get_client_id(), + "client_secret": self.get_client_secret(), "grant_type": "refresh_token", "refresh_token": refresh_token, } diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index cca823e52f8ddd..5ffaea31584feb 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -265,7 +265,7 @@ def env(key, default='', type=None): 'sentry.lang.javascript', 'sentry.lang.native', 'sentry.plugins.sentry_interface_types', 'sentry.plugins.sentry_mail', 'sentry.plugins.sentry_urls', 'sentry.plugins.sentry_useragents', 'sentry.plugins.sentry_webhooks', 'social_auth', 'sudo', 'sentry.tagstore', - 'sentry.eventstream', + 'sentry.eventstream', 'sentry.auth.providers.google', ) import django diff --git a/src/sentry/runner/initializer.py b/src/sentry/runner/initializer.py index 2e1b26b96b4743..d237397a7daaa9 100644 --- a/src/sentry/runner/initializer.py +++ b/src/sentry/runner/initializer.py @@ -427,13 +427,16 @@ def apply_legacy_settings(settings): settings.CELERY_ALWAYS_EAGER = (not settings.SENTRY_USE_QUEUE) for old, new in ( - ('SENTRY_ADMIN_EMAIL', 'system.admin-email'), ('SENTRY_URL_PREFIX', 'system.url-prefix'), - ('SENTRY_SYSTEM_MAX_EVENTS_PER_MINUTE', - 'system.rate-limit'), ('SENTRY_ENABLE_EMAIL_REPLIES', 'mail.enable-replies'), - ('SENTRY_SMTP_HOSTNAME', - 'mail.reply-hostname'), ('MAILGUN_API_KEY', 'mail.mailgun-api-key'), - ('SENTRY_FILESTORE', - 'filestore.backend'), ('SENTRY_FILESTORE_OPTIONS', 'filestore.options'), + ('SENTRY_ADMIN_EMAIL', 'system.admin-email'), + ('SENTRY_URL_PREFIX', 'system.url-prefix'), + ('SENTRY_SYSTEM_MAX_EVENTS_PER_MINUTE', 'system.rate-limit'), + ('SENTRY_ENABLE_EMAIL_REPLIES', 'mail.enable-replies'), + ('SENTRY_SMTP_HOSTNAME', 'mail.reply-hostname'), + ('MAILGUN_API_KEY', 'mail.mailgun-api-key'), + ('SENTRY_FILESTORE', 'filestore.backend'), + ('SENTRY_FILESTORE_OPTIONS', 'filestore.options'), + ('GOOGLE_CLIENT_ID', 'auth-google.client-id'), + ('GOOGLE_CLIENT_SECRET', 'auth-google.client-secret'), ): if new not in settings.SENTRY_OPTIONS and hasattr(settings, old): warnings.warn(DeprecatedSettingWarning(old, "SENTRY_OPTIONS['%s']" % new)) diff --git a/tests/sentry/auth/providers/google/test_provider.py b/tests/sentry/auth/providers/google/test_provider.py new file mode 100644 index 00000000000000..3eec58f7f21ff2 --- /dev/null +++ b/tests/sentry/auth/providers/google/test_provider.py @@ -0,0 +1,73 @@ +from __future__ import absolute_import + +import pytest + +from sentry.auth.exceptions import IdentityNotValid +from sentry.models import AuthIdentity, AuthProvider +from sentry.testutils import TestCase + +from sentry.auth.providers.google.constants import DATA_VERSION + + +class GoogleOAuth2ProviderTest(TestCase): + def setUp(self): + self.org = self.create_organization(owner=self.user) + self.user = self.create_user('foo@example.com') + self.auth_provider = AuthProvider.objects.create( + provider='google', + organization=self.org, + ) + super(GoogleOAuth2ProviderTest, self).setUp() + + def test_refresh_identity_without_refresh_token(self): + auth_identity = AuthIdentity.objects.create( + auth_provider=self.auth_provider, + user=self.user, + data={ + 'access_token': 'access_token', + } + ) + + provider = self.auth_provider.get_provider() + + with pytest.raises(IdentityNotValid): + provider.refresh_identity(auth_identity) + + def test_handles_multiple_domains(self): + self.auth_provider.update( + config={'domains': ['example.com']}, + ) + + provider = self.auth_provider.get_provider() + assert provider.domains == ['example.com'] + + def test_handles_legacy_single_domain(self): + self.auth_provider.update( + config={'domain': 'example.com'}, + ) + + provider = self.auth_provider.get_provider() + assert provider.domains == ['example.com'] + + def test_build_config(self): + provider = self.auth_provider.get_provider() + state = { + 'domain': 'example.com', + 'user': { + 'iss': 'accounts.google.com', + 'at_hash': 'HK6E_P6Dh8Y93mRNtsDB1Q', + 'email_verified': 'true', + 'sub': '10769150350006150715113082367', + 'azp': '1234987819200.apps.googleusercontent.com', + 'email': 'jsmith@example.com', + 'aud': '1234987819200.apps.googleusercontent.com', + 'iat': 1353601026, + 'exp': 1353604926, + 'hd': 'example.com' + }, + } + result = provider.build_config(state) + assert result == { + 'domains': ['example.com'], + 'version': DATA_VERSION, + }