Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/sentry/auth/providers/google/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
)
18 changes: 18 additions & 0 deletions src/sentry/auth/providers/google/constants.py
Original file line number Diff line number Diff line change
@@ -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'
116 changes: 116 additions & 0 deletions src/sentry/auth/providers/google/provider.py
Original file line number Diff line number Diff line change
@@ -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'],
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<h3>Domain</h3>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to see this template live with other templates, but this is fine 🤷‍♂️

This comment was marked as resolved.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo something we can follow up on - theres improvements we need to make to SSO for onpremise at some point anyways


<p>Users will be allowed to authenticate if they have an account under the Google Apps domain <strong>{{ domains|join:" or " }}</strong>.</p>
8 changes: 8 additions & 0 deletions src/sentry/auth/providers/google/utils.py
Original file line number Diff line number Diff line change
@@ -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)
81 changes: 81 additions & 0 deletions src/sentry/auth/providers/google/views.py
Original file line number Diff line number Diff line change
@@ -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]
16 changes: 11 additions & 5 deletions src/sentry/auth/providers/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
),
]

Expand All @@ -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,
}
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions src/sentry/runner/initializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
73 changes: 73 additions & 0 deletions tests/sentry/auth/providers/google/test_provider.py
Original file line number Diff line number Diff line change
@@ -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,
}