From 6aa530318b5a348f38f9ceb6c3ede52f76783652 Mon Sep 17 00:00:00 2001 From: Nick Bruun Date: Sat, 4 Jul 2015 18:21:38 +0200 Subject: [PATCH 1/7] Add GitHub as authentication backend. --- freight/web/auth.py | 113 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 93 insertions(+), 20 deletions(-) diff --git a/freight/web/auth.py b/freight/web/auth.py index d3fb8e1e..55c7a588 100644 --- a/freight/web/auth.py +++ b/freight/web/auth.py @@ -1,6 +1,7 @@ from __future__ import absolute_import import freight +import requests from flask import current_app, redirect, request, session, url_for from flask.views import MethodView @@ -14,28 +15,100 @@ GOOGLE_REVOKE_URI = 'https://accounts.google.com/o/oauth2/revoke' GOOGLE_TOKEN_URI = 'https://accounts.google.com/o/oauth2/token' +GITHUB_AUTH_URI = 'https://github.com/login/oauth/authorize' +GITHUB_TOKEN_URI = 'https://github.com/login/oauth/access_token' +GITHUB_API_USER_URI = 'https://api.github.com/user' +GITHUB_API_USER_TEAMS_URI = 'https://api.github.com/user/teams' + + +class GitHubOAuth2WebServerFlow(OAuth2WebServerFlow): + """GitHub-specific OAuth 2.0 web server flow. + """ + + def step2_exchange(self, code, http=None): + # Perform the exchange. + resp = super(GitHubOAuth2WebServerFlow, self) \ + .step2_exchange(code, http) + + # Get the user's e-mail address, username, organization and team IDs + # if the scopes match. + scopes = frozenset(self.scope.split(',')) + id_token_additions = {} + + if 'user' in scopes: + # Fetch user information. + user_resp = requests.get(GITHUB_API_USER_URI, + params={'access_token': + resp.access_token}) + user_resp.raise_for_status() + user_resp_json = user_resp.json() + + id_token_additions['email'] = user_resp_json['email'] + id_token_additions['login'] = user_resp_json['login'] + id_token_additions['id'] = user_resp_json['id'] + + # Fetch teams. + teams_resp = requests.get(GITHUB_API_USER_TEAMS_URI, + params={'access_token': + resp.access_token}) + teams_resp.raise_for_status() + + team_ids = set() + organization_ids = set() + + for team in teams_resp.json(): + team_ids.add(team['id']) + organization_ids.add(team['organization']['id']) + + id_token_additions['team_ids'] = list(team_ids) + id_token_additions['organization_ids'] = list(organization_ids) + + if resp.id_token: + resp.id_token.update(id_token_additions) + else: + resp.id_token = id_token_additions + + return resp + def get_auth_flow(redirect_uri=None): - # XXX(dcramer): we have to generate this each request because oauth2client - # doesn't want you to set redirect_uri as part of the request, which causes - # a lot of runtime issues. - auth_uri = GOOGLE_AUTH_URI - if current_app.config['GOOGLE_DOMAIN']: - auth_uri = auth_uri + '?hd=' + current_app.config['GOOGLE_DOMAIN'] - - return OAuth2WebServerFlow( - client_id=current_app.config['GOOGLE_CLIENT_ID'], - client_secret=current_app.config['GOOGLE_CLIENT_SECRET'], - scope='https://www.googleapis.com/auth/userinfo.email', - redirect_uri=redirect_uri, - user_agent='freight/{0} (python {1})'.format( - freight.VERSION, - PYTHON_VERSION, - ), - auth_uri=auth_uri, - token_uri=GOOGLE_TOKEN_URI, - revoke_uri=GOOGLE_REVOKE_URI, - ) + # Determine the flow class and arguments to use with the authentication + # backend. + if 'GITHUB_CLIENT_ID' in current_app.config and \ + 'GITHUB_CLIENT_SECRET' in current_app.config: + return GitHubOAuth2WebServerFlow( + client_id=current_app.config['GITHUB_CLIENT_ID'], + client_secret=current_app.config['GITHUB_CLIENT_SECRET'], + scope='user', + redirect_uri=redirect_uri, + user_agent='freight/{0} (python {1})'.format( + freight.VERSION, + PYTHON_VERSION, + ), + auth_uri=GITHUB_AUTH_URI, + token_uri=GITHUB_TOKEN_URI + ) + else: + # XXX(dcramer): we have to generate this each request because + # oauth2client doesn't want you to set redirect_uri as part of the + # request, which causes a lot of runtime issues. + auth_uri = GOOGLE_AUTH_URI + if current_app.config['GOOGLE_DOMAIN']: + auth_uri = auth_uri + '?hd=' + current_app.config['GOOGLE_DOMAIN'] + + return OAuth2WebServerFlow( + client_id=current_app.config['GOOGLE_CLIENT_ID'], + client_secret=current_app.config['GOOGLE_CLIENT_SECRET'], + scope='https://www.googleapis.com/auth/userinfo.email', + redirect_uri=redirect_uri, + user_agent='freight/{0} (python {1})'.format( + freight.VERSION, + PYTHON_VERSION, + ), + auth_uri=auth_uri, + token_uri=GOOGLE_TOKEN_URI, + revoke_uri=GOOGLE_REVOKE_URI, + ) class LoginView(MethodView): From 4ecf3d20c0318dfab345ce8635a83f48fb431c44 Mon Sep 17 00:00:00 2001 From: Nick Bruun Date: Sat, 4 Jul 2015 18:27:30 +0200 Subject: [PATCH 2/7] Allow for specification of PostgreSQL database template for testing. --- conftest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/conftest.py b/conftest.py index 276b5c42..ed42e58c 100644 --- a/conftest.py +++ b/conftest.py @@ -44,7 +44,11 @@ def reset_database(request, app): engine.dispose() subprocess.check_call('dropdb %s' % db_name, shell=True) - subprocess.check_call('createdb -E utf-8 %s' % db_name, shell=True) + subprocess.check_call('createdb -E utf-8 %s%s' % + (' -T %s ' % (os.environ['POSTGRESQL_TEMPLATE']) + if 'POSTGRESQL_TEMPLATE' in os.environ else '', + db_name), + shell=True) command.upgrade(ALEMBIC_CONFIG, 'head') return lambda: reset_database(request, app) From b90e0f2fe710c8c0c961109262d630677e1b4d38 Mon Sep 17 00:00:00 2001 From: Nick Bruun Date: Sat, 4 Jul 2015 19:31:51 +0200 Subject: [PATCH 3/7] Add configuration parsing of GitHub authentication. --- freight/config.py | 42 ++++++++++++++++++++++++++++++++++++++++-- freight/web/auth.py | 28 +++++++++++++++++++++------- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/freight/config.py b/freight/config.py index 12179f7f..d71965f3 100644 --- a/freight/config.py +++ b/freight/config.py @@ -64,10 +64,15 @@ def create_app(_read_config=True, **config): app.config['LOG_LEVEL'] = os.environ.get('LOG_LEVEL', 'INFO' if config.get('DEBUG') else 'ERROR') - # Currently authentication requires Google + # Currently authentication defaults to Google. + app.config['AUTH_BACKEND'] = os.environ.get('AUTH_BACKEND', 'google') app.config['GOOGLE_CLIENT_ID'] = os.environ.get('GOOGLE_CLIENT_ID') app.config['GOOGLE_CLIENT_SECRET'] = os.environ.get('GOOGLE_CLIENT_SECRET') app.config['GOOGLE_DOMAIN'] = os.environ.get('GOOGLE_DOMAIN') + app.config['GITHUB_CLIENT_ID'] = os.environ.get('GITHUB_CLIENT_ID') + app.config['GITHUB_CLIENT_SECRET'] = os.environ.get('GITHUB_CLIENT_SECRET') + app.config['GITHUB_TEAM_ID'] = os.environ.get('GITHUB_TEAM_ID') + app.config['GITHUB_ORGANIZATION_ID'] = os.environ.get('GITHUB_ORGANIZATION_ID') # Generate a GitHub token via Curl: # curlish https://api.github.com/authorizations \ @@ -160,6 +165,7 @@ def create_app(_read_config=True, **config): configure_redis(app) configure_sqlalchemy(app) configure_web_routes(app) + configure_auth(app) return app @@ -195,6 +201,38 @@ def configure_api(app): api.init_app(app) +def configure_auth(app): + if not app.config['AUTH_BACKEND']: + app.config['AUTH_BACKEND'] = 'google' + elif app.config['AUTH_BACKEND'] not in {'google', 'github'}: + raise RuntimeError('invalid authentication backend: %s' % + (app.config['AUTH_BACKEND'])) + + if app.config['AUTH_BACKEND'] == 'github': + if not app.config['GITHUB_CLIENT_ID'] or \ + not app.config['GITHUB_CLIENT_SECRET']: + raise RuntimeError('GitHub authentication requires a client ID ' + 'and secret to be provided') + + if app.config['GITHUB_TEAM_ID']: + try: + app.config['GITHUB_TEAM_ID'] = \ + long(app.config['GITHUB_TEAM_ID']) + except ValueError: + raise RuntimeError('invalid team ID: %s' % + (app.config['GITHUB_TEAM_ID'])) + elif app.config['GITHUB_ORGANIZATION_ID']: + try: + app.config['GITHUB_ORGANIZATION_ID'] = \ + long(app.config['GITHUB_ORGANIZATION_ID']) + except ValueError: + raise RuntimeError('invalid organization ID: %s' % + (app.config['GITHUB_ORGANIZATION_ID'])) + else: + raise RuntimeError('either a team or an organization ID must be ' + 'configured for GitHub authentication') + + def configure_celery(app): celery.init_app(app) @@ -244,7 +282,7 @@ def configure_web_routes(app): view_func=LogoutView.as_view(b'logout', complete_url='index')) app.add_url_rule( '/auth/complete/', - view_func=AuthorizedView.as_view(b'authorized', authorized_url='authorized', complete_url='index')) + view_func=AuthorizedView.as_view(b'authorized', authorized_url='authorized', complete_url='index', login_url='login')) index_view = IndexView.as_view(b'index', login_url='login') app.add_url_rule('/', view_func=index_view) diff --git a/freight/web/auth.py b/freight/web/auth.py index 55c7a588..ac6442e3 100644 --- a/freight/web/auth.py +++ b/freight/web/auth.py @@ -3,9 +3,9 @@ import freight import requests -from flask import current_app, redirect, request, session, url_for +from flask import current_app, redirect, request, session, url_for, abort from flask.views import MethodView -from oauth2client.client import OAuth2WebServerFlow +from oauth2client.client import OAuth2WebServerFlow, FlowExchangeError from freight.config import db from freight.constants import PYTHON_VERSION @@ -74,8 +74,7 @@ def step2_exchange(self, code, http=None): def get_auth_flow(redirect_uri=None): # Determine the flow class and arguments to use with the authentication # backend. - if 'GITHUB_CLIENT_ID' in current_app.config and \ - 'GITHUB_CLIENT_SECRET' in current_app.config: + if current_app.config['AUTH_BACKEND'] == 'github': return GitHubOAuth2WebServerFlow( client_id=current_app.config['GITHUB_CLIENT_ID'], client_secret=current_app.config['GITHUB_CLIENT_SECRET'], @@ -124,20 +123,35 @@ def get(self): class AuthorizedView(MethodView): - def __init__(self, complete_url, authorized_url): + def __init__(self, complete_url, authorized_url, login_url): self.complete_url = complete_url self.authorized_url = authorized_url + self.login_url = login_url super(AuthorizedView, self).__init__() def get(self): redirect_uri = url_for(self.authorized_url, _external=True) flow = get_auth_flow(redirect_uri=redirect_uri) - resp = flow.step2_exchange(request.args['code']) - if current_app.config['GOOGLE_DOMAIN']: + try: + resp = flow.step2_exchange(request.args['code']) + except FlowExchangeError: + return redirect(url_for(self.login_url)) + + if current_app.config['AUTH_BACKEND'] == 'google' and \ + current_app.config['GOOGLE_DOMAIN']: if resp.id_token.get('hd') != current_app.config['GOOGLE_DOMAIN']: # TODO(dcramer): this should show some kind of error return redirect(url_for(self.complete_url)) + elif current_app.config['AUTH_BACKEND'] == 'github': + if current_app.config['GITHUB_TEAM_ID']: + if current_app.config['GITHUB_TEAM_ID'] not in \ + resp.id_token['team_ids']: + abort(403) + elif current_app.config['GITHUB_ORGANIZATION_ID']: + if current_app.config['GITHUB_ORGANIZATION_ID'] not in \ + resp.id_token['organization_ids']: + abort(403) user = User.query.filter( User.name == resp.id_token['email'], From 3352119377a07a62c3ab0311daa217a95e077536 Mon Sep 17 00:00:00 2001 From: Nick Bruun Date: Sat, 4 Jul 2015 19:45:05 +0200 Subject: [PATCH 4/7] Simplify handling of exchange errors. --- freight/config.py | 2 +- freight/web/auth.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/freight/config.py b/freight/config.py index d71965f3..b38f6dbc 100644 --- a/freight/config.py +++ b/freight/config.py @@ -282,7 +282,7 @@ def configure_web_routes(app): view_func=LogoutView.as_view(b'logout', complete_url='index')) app.add_url_rule( '/auth/complete/', - view_func=AuthorizedView.as_view(b'authorized', authorized_url='authorized', complete_url='index', login_url='login')) + view_func=AuthorizedView.as_view(b'authorized', authorized_url='authorized', complete_url='index')) index_view = IndexView.as_view(b'index', login_url='login') app.add_url_rule('/', view_func=index_view) diff --git a/freight/web/auth.py b/freight/web/auth.py index ac6442e3..3c8d82bc 100644 --- a/freight/web/auth.py +++ b/freight/web/auth.py @@ -60,8 +60,8 @@ def step2_exchange(self, code, http=None): team_ids.add(team['id']) organization_ids.add(team['organization']['id']) - id_token_additions['team_ids'] = list(team_ids) - id_token_additions['organization_ids'] = list(organization_ids) + id_token_additions['team_ids'] = team_ids + id_token_additions['organization_ids'] = organization_ids if resp.id_token: resp.id_token.update(id_token_additions) @@ -123,10 +123,9 @@ def get(self): class AuthorizedView(MethodView): - def __init__(self, complete_url, authorized_url, login_url): + def __init__(self, complete_url, authorized_url): self.complete_url = complete_url self.authorized_url = authorized_url - self.login_url = login_url super(AuthorizedView, self).__init__() def get(self): @@ -136,7 +135,10 @@ def get(self): try: resp = flow.step2_exchange(request.args['code']) except FlowExchangeError: - return redirect(url_for(self.login_url)) + # If the flow breaks, one likely condition is that we've been given + # an expired code for the exchange. Redirect the user to the + # authentication provider again. + return redirect(flow.step1_get_authorize_url()) if current_app.config['AUTH_BACKEND'] == 'google' and \ current_app.config['GOOGLE_DOMAIN']: From 5aad603b41e77a33e945269b3977d03deea690ee Mon Sep 17 00:00:00 2001 From: Nick Bruun Date: Tue, 7 Jul 2015 10:49:24 +0200 Subject: [PATCH 5/7] Abstract auth providers. --- freight/web/auth.py | 331 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 255 insertions(+), 76 deletions(-) diff --git a/freight/web/auth.py b/freight/web/auth.py index 3c8d82bc..d94b821f 100644 --- a/freight/web/auth.py +++ b/freight/web/auth.py @@ -1,6 +1,7 @@ from __future__ import absolute_import import freight +import logging import requests from flask import current_app, redirect, request, session, url_for, abort @@ -21,48 +22,188 @@ GITHUB_API_USER_TEAMS_URI = 'https://api.github.com/user/teams' -class GitHubOAuth2WebServerFlow(OAuth2WebServerFlow): - """GitHub-specific OAuth 2.0 web server flow. +logger = logging.getLogger(__name__) + + +class AccessDeniedError(Exception): + """Access denied. + + Raised if authentication with the backend succeeded but the user is not + allowed access by configuration. + """ + + pass + + +class OAuth2Provider(object): + """OAuth 2.0 provider. """ - def step2_exchange(self, code, http=None): - # Perform the exchange. - resp = super(GitHubOAuth2WebServerFlow, self) \ - .step2_exchange(code, http) + def __init__(self, + client_id, + client_secret, + scope, + auth_uri, + token_uri, + revoke_uri=None): + """Initialize an OAuth 2.0 provider. + + :param client_id: Client ID. + :param client_secret: Client secret. + :param scope: Scope. + :param auth_uri: Authentication URI. + :param token_uri: Token exchange URI. + :param revoke_uri: Token revocation URI. + """ + + self.client_id = client_id + self.client_secret = client_secret + self.scope = scope + self.auth_uri = auth_uri + self.token_uri = token_uri + self.revoke_uri = revoke_uri + + def _get_flow(self, redirect_uri=None): + """Get authorization flow. + + :param redirect_uri: Redirect URI. + :rtype: :class:`oauth2client.client.OAuth2WebServerFlow` + """ + + return OAuth2WebServerFlow( + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope, + redirect_uri=redirect_uri, + user_agent='freight/{0} (python {1})'.format( + freight.VERSION, + PYTHON_VERSION, + ), + auth_uri=self.auth_uri, + token_uri=self.token_uri, + revoke_uri=self.revoke_uri + ) + + def step1_get_authorize_url(self, redirect_uri=None): + """Get provider authorize URL. + + :param redirect_uri: Optional redirect URI. + :returns: the authorize URL For the provider to redirect the user to. + """ + + return self._get_flow(redirect_uri).step1_get_authorize_url() + + def step2_exchange(self, code, redirect_uri=None): + """Exchange an authorization code for an access token. + + :param code: Authorization code. + :param redirect_uri: Redirect URI. + :returns: the exchange result. + :rtype: :class:`oauth2client.client.OAuth2Credentials` + :raises AccessDeniedError: + if the authenticated user does not have access by the configured + restrictions. + :raises oauth2client.client.FlowExchangeError: + if an error occured during the exchange, most likely due to an + already used code or a bad scope. + """ + + return self._get_flow(redirect_uri).step2_exchange(code) + + +class GitHubOAuth2Provider(OAuth2Provider): + """GitHub OAuth 2.0 provider. + """ + + def __init__(self, + client_id, + client_secret, + team_id=None, + organization_id=None, + scope='user'): + """Initialize a GitHub OAuth 2.0 provider. + + :param client_id: Client ID. + :param client_secret: Client secret. + :param team_id: Optional team ID to limit authorization to. + :type team_id: :class:`int`, :class:`long` + :param organization_id: + Optional organization ID to limit authorization to. + :type organization_id: :class:`int`, :class:`long` + :param scope: Scope. Default ``user``. + """ + + self.team_id = team_id + self.organization_id = organization_id + + super(GitHubOAuth2Provider, self).__init__( + client_id=client_id, + client_secret=client_secret, + scope=scope, + auth_uri=GITHUB_AUTH_URI, + token_uri=GITHUB_TOKEN_URI + ) + + def _get_github_api_json(self, url, access_token): + """Perform a GET request against the GitHub API. - # Get the user's e-mail address, username, organization and team IDs - # if the scopes match. - scopes = frozenset(self.scope.split(',')) + :param url: URL. + :param access_token: Access token. + :returns: the JSON response body on success. + """ + + resp = requests.get(url, params={'access_token': access_token}) + + if resp.status_code == 200: + return resp.json() + elif resp.status_code == 404: + # GitHub will return 404 if the scope does not allow access to + # the requested endpoint. For now, let's raise a FlowExchangeError + # to force re-authorization, hopefully with the right scope. + raise FlowExchangeError('insufficient_scope') + + # Any other error is hard to deal with. Let's raise it and propagate + # the exception to, say, Sentry. + resp.raise_for_status() + + def step1_get_authorize_url(self, redirect_uri=None): + return self._get_flow(redirect_uri).step1_get_authorize_url() + + def step2_exchange(self, code, redirect_uri=None): + resp = super(GitHubOAuth2Provider, self).step2_exchange(code, + redirect_uri) + + # Fetch the user's profile information. id_token_additions = {} - if 'user' in scopes: - # Fetch user information. - user_resp = requests.get(GITHUB_API_USER_URI, - params={'access_token': - resp.access_token}) - user_resp.raise_for_status() - user_resp_json = user_resp.json() + user_json = self._get_github_api_json(GITHUB_API_USER_URI, + resp.access_token) - id_token_additions['email'] = user_resp_json['email'] - id_token_additions['login'] = user_resp_json['login'] - id_token_additions['id'] = user_resp_json['id'] + id_token_additions['email'] = user_json['email'] + id_token_additions['login'] = user_json['login'] + id_token_additions['id'] = user_json['id'] - # Fetch teams. - teams_resp = requests.get(GITHUB_API_USER_TEAMS_URI, - params={'access_token': - resp.access_token}) - teams_resp.raise_for_status() + # Fetch the user's teams. + teams_json = self._get_github_api_json(GITHUB_API_USER_TEAMS_URI, + resp.access_token) - team_ids = set() - organization_ids = set() + team_ids = set() + organization_ids = set() - for team in teams_resp.json(): - team_ids.add(team['id']) - organization_ids.add(team['organization']['id']) + for team in teams_json: + team_ids.add(team['id']) + organization_ids.add(team['organization']['id']) - id_token_additions['team_ids'] = team_ids - id_token_additions['organization_ids'] = organization_ids + id_token_additions['team_ids'] = team_ids + id_token_additions['organization_ids'] = organization_ids + # Validate that the user should be permitted access. + if (self.team_id and self.team_id not in team_ids) or \ + (self.organization_id and + self.organization_id not in organization_ids): + raise AccessDeniedError() + + # Update the response. if resp.id_token: resp.id_token.update(id_token_additions) else: @@ -71,44 +212,91 @@ def step2_exchange(self, code, http=None): return resp -def get_auth_flow(redirect_uri=None): - # Determine the flow class and arguments to use with the authentication - # backend. - if current_app.config['AUTH_BACKEND'] == 'github': - return GitHubOAuth2WebServerFlow( - client_id=current_app.config['GITHUB_CLIENT_ID'], - client_secret=current_app.config['GITHUB_CLIENT_SECRET'], - scope='user', - redirect_uri=redirect_uri, - user_agent='freight/{0} (python {1})'.format( - freight.VERSION, - PYTHON_VERSION, - ), - auth_uri=GITHUB_AUTH_URI, - token_uri=GITHUB_TOKEN_URI +class GoogleOAuth2Provider(OAuth2Provider): + """Google OAuth 2.0 privder. + """ + + def __init__(self, + client_id, + client_secret, + domain=None, + scope='https://www.googleapis.com/auth/userinfo.email'): + """Initialize a Google OAuth 2.0 provider. + + :param client_id: Client ID. + :param client_secret: Client secret. + :param domain: Optional domain to limit authorization to. + :param scope: Scope. + """ + + self.domain = domain + + super(GoogleOAuth2Provider, self).__init__( + client_id=client_id, + client_secret=client_secret, + scope=scope, + auth_uri=GOOGLE_AUTH_URI, + token_uri=GOOGLE_TOKEN_URI, + revoke_uri=GOOGLE_REVOKE_URI ) - else: + + def get_flow(self, redirect_uri=None): # XXX(dcramer): we have to generate this each request because # oauth2client doesn't want you to set redirect_uri as part of the # request, which causes a lot of runtime issues. - auth_uri = GOOGLE_AUTH_URI - if current_app.config['GOOGLE_DOMAIN']: - auth_uri = auth_uri + '?hd=' + current_app.config['GOOGLE_DOMAIN'] + auth_uri = self.auth_uri + if self.domain: + auth_uri = auth_uri + '?hd=' + self.domain return OAuth2WebServerFlow( - client_id=current_app.config['GOOGLE_CLIENT_ID'], - client_secret=current_app.config['GOOGLE_CLIENT_SECRET'], - scope='https://www.googleapis.com/auth/userinfo.email', + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope, redirect_uri=redirect_uri, user_agent='freight/{0} (python {1})'.format( freight.VERSION, PYTHON_VERSION, ), auth_uri=auth_uri, - token_uri=GOOGLE_TOKEN_URI, - revoke_uri=GOOGLE_REVOKE_URI, + token_uri=self.token_uri, + revoke_uri=self.revoke_uri ) + def step2_exchange(self, code, redirect_uri=None): + resp = super(GoogleOAuth2Provider, self).step2_exchange(code, + redirect_uri) + + # Validate the domain. + if self.domain and resp.id_token.get('hd') != self.domain: + raise AccessDeniedError('domain %s does not match %s' % + (resp.id_token.get('hd'), self.domain)) + + return resp + + +def setup_github_provider(config): + return GitHubOAuth2Provider( + client_id=config['GITHUB_CLIENT_ID'], + client_secret=config['GITHUB_CLIENT_SECRET'], + team_id=config['GITHUB_TEAM_ID'], + organization_id=config['GITHUB_ORGANIZATION_ID'] + ) + + +def setup_google_provider(config): + return GoogleOAuth2Provider( + client_id=config['GOOGLE_CLIENT_ID'], + client_secret=config['GOOGLE_CLIENT_SECRET'], + domain=config['GOOGLE_DOMAIN'], + ) + + +def get_auth_provider(): + return { + 'github': setup_github_provider, + 'google': setup_google_provider, + }[current_app.config['AUTH_BACKEND']](current_app.config) + class LoginView(MethodView): def __init__(self, authorized_url): @@ -117,8 +305,8 @@ def __init__(self, authorized_url): def get(self): redirect_uri = url_for(self.authorized_url, _external=True) - flow = get_auth_flow(redirect_uri=redirect_uri) - auth_uri = flow.step1_get_authorize_url() + provider = get_auth_provider() + auth_uri = provider.step1_get_authorize_url(redirect_uri=redirect_uri) return redirect(auth_uri) @@ -130,30 +318,21 @@ def __init__(self, complete_url, authorized_url): def get(self): redirect_uri = url_for(self.authorized_url, _external=True) - flow = get_auth_flow(redirect_uri=redirect_uri) + provider = get_auth_provider() try: - resp = flow.step2_exchange(request.args['code']) - except FlowExchangeError: + resp = provider.step2_exchange(request.args['code'], redirect_uri) + except FlowExchangeError as e: # If the flow breaks, one likely condition is that we've been given # an expired code for the exchange. Redirect the user to the # authentication provider again. - return redirect(flow.step1_get_authorize_url()) - - if current_app.config['AUTH_BACKEND'] == 'google' and \ - current_app.config['GOOGLE_DOMAIN']: - if resp.id_token.get('hd') != current_app.config['GOOGLE_DOMAIN']: - # TODO(dcramer): this should show some kind of error - return redirect(url_for(self.complete_url)) - elif current_app.config['AUTH_BACKEND'] == 'github': - if current_app.config['GITHUB_TEAM_ID']: - if current_app.config['GITHUB_TEAM_ID'] not in \ - resp.id_token['team_ids']: - abort(403) - elif current_app.config['GITHUB_ORGANIZATION_ID']: - if current_app.config['GITHUB_ORGANIZATION_ID'] not in \ - resp.id_token['organization_ids']: - abort(403) + logger.warning('OAuth 2.0 code exchange failed: %s ' + '(redirect URI: %s)' % (e, redirect_uri)) + return redirect(provider.step1_get_authorize_url(redirect_uri)) + except AccessDeniedError: + # If access denied, abort with a 403 Forbidden. + logger.info('access denied for OAuth 2.0 authorization') + abort(403) user = User.query.filter( User.name == resp.id_token['email'], From 3b3e6530484858760e901864bb41780d79e8745e Mon Sep 17 00:00:00 2001 From: Nick Bruun Date: Fri, 10 Jul 2015 11:01:11 +0200 Subject: [PATCH 6/7] Refactor authentication provider to Flask extension. --- freight/auth/__init__.py | 37 ++++ freight/auth/exceptions.py | 15 ++ freight/auth/providers.py | 342 +++++++++++++++++++++++++++++++++++++ freight/config.py | 39 +---- freight/web/auth.py | 284 +----------------------------- 5 files changed, 402 insertions(+), 315 deletions(-) create mode 100644 freight/auth/__init__.py create mode 100644 freight/auth/exceptions.py create mode 100644 freight/auth/providers.py diff --git a/freight/auth/__init__.py b/freight/auth/__init__.py new file mode 100644 index 00000000..e13f0f21 --- /dev/null +++ b/freight/auth/__init__.py @@ -0,0 +1,37 @@ +from .exceptions import AccessDeniedError, ProviderConfigurationError # noqa +from .providers import GoogleOAuth2Provider, GitHubOAuth2Provider + + +class Auth(object): + """Flask extension for OAuth 2.0 authentication. + + Assigns the configured provider to the ``auth_provider`` key of + ``app.state`` when initialized. + """ + + def __init__(self, app=None): + if app is not None: + self.init_app(app) + + def init_app(self, app): + # For compatibility with previous versions of Freight, default to using + # Google as the authentication backend. + backend = app.config.get('AUTH_BACKEND') + if not backend: + backend = 'google' + + # Resolve the provider setup function. + try: + provider_cls = { + 'google': GoogleOAuth2Provider, + 'github': GitHubOAuth2Provider, + }[backend] + except KeyError: + raise RuntimeError('invalid authentication backend: %s' % + (backend)) + + # Set up the provider. + if not hasattr(app, 'state'): + app.state = {} + + app.state['auth_provider'] = provider_cls.from_app_config(app.config) diff --git a/freight/auth/exceptions.py b/freight/auth/exceptions.py new file mode 100644 index 00000000..5b6755ca --- /dev/null +++ b/freight/auth/exceptions.py @@ -0,0 +1,15 @@ +class AccessDeniedError(Exception): + """Access denied. + + Raised if authentication with the backend succeeded but the user is not + allowed access by configuration. + """ + + pass + + +class ProviderConfigurationError(Exception): + """Provider configuration error. + """ + + pass diff --git a/freight/auth/providers.py b/freight/auth/providers.py new file mode 100644 index 00000000..ccca199d --- /dev/null +++ b/freight/auth/providers.py @@ -0,0 +1,342 @@ +import freight +import logging +import requests +import warnings + +from freight.constants import PYTHON_VERSION +from oauth2client.client import OAuth2WebServerFlow, FlowExchangeError + +from .exceptions import AccessDeniedError, ProviderConfigurationError + + +logger = logging.getLogger(__name__) + + +GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/auth' +GOOGLE_REVOKE_URI = 'https://accounts.google.com/o/oauth2/revoke' +GOOGLE_TOKEN_URI = 'https://accounts.google.com/o/oauth2/token' + +GITHUB_AUTH_URI = 'https://github.com/login/oauth/authorize' +GITHUB_TOKEN_URI = 'https://github.com/login/oauth/access_token' +GITHUB_API_USER_URI = 'https://api.github.com/user' +GITHUB_API_USER_TEAMS_URI = 'https://api.github.com/user/teams' + + +class OAuth2Provider(object): + """OAuth 2.0 provider. + """ + + def __init__(self, + client_id, + client_secret, + scope, + auth_uri, + token_uri, + revoke_uri=None): + """Initialize an OAuth 2.0 provider. + + :param client_id: Client ID. + :param client_secret: Client secret. + :param scope: Scope. + :param auth_uri: Authentication URI. + :param token_uri: Token exchange URI. + :param revoke_uri: Token revocation URI. + """ + + self.client_id = client_id + self.client_secret = client_secret + self.scope = scope + self.auth_uri = auth_uri + self.token_uri = token_uri + self.revoke_uri = revoke_uri + + @classmethod + def from_app_config(cls, config): + """Instantiate provider from application configuration. + + :param config: Configuration. + :type config: :class:`dict` + :returns: the provider instance based on the configuration. + :raises freight.auth.exceptions.ProviderConfigurationError: + if the application configuration for the authentication provider + is not valid. + """ + + raise NotImplementedError() + + def _get_flow(self, redirect_uri=None): + """Get authorization flow. + + :param redirect_uri: Redirect URI. + :rtype: :class:`oauth2client.client.OAuth2WebServerFlow` + """ + + return OAuth2WebServerFlow( + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope, + redirect_uri=redirect_uri, + user_agent='freight/{0} (python {1})'.format( + freight.VERSION, + PYTHON_VERSION, + ), + auth_uri=self.auth_uri, + token_uri=self.token_uri, + revoke_uri=self.revoke_uri + ) + + def step1_get_authorize_url(self, redirect_uri=None): + """Get provider authorize URL. + + :param redirect_uri: Optional redirect URI. + :returns: the authorize URL For the provider to redirect the user to. + """ + + return self._get_flow(redirect_uri).step1_get_authorize_url() + + def step2_exchange(self, code, redirect_uri=None): + """Exchange an authorization code for an access token. + + :param code: Authorization code. + :param redirect_uri: Redirect URI. + :returns: the exchange result. + :rtype: :class:`oauth2client.client.OAuth2Credentials` + :raises freight.auth.exceptions.AccessDeniedError: + if the authenticated user does not have access by the configured + restrictions. + :raises oauth2client.client.FlowExchangeError: + if an error occured during the exchange, most likely due to an + already used code or a bad scope. + """ + + return self._get_flow(redirect_uri).step2_exchange(code) + + +class GoogleOAuth2Provider(OAuth2Provider): + """Google OAuth 2.0 privder. + """ + + def __init__(self, + client_id, + client_secret, + domain=None, + scope='https://www.googleapis.com/auth/userinfo.email'): + """Initialize a Google OAuth 2.0 provider. + + :param client_id: Client ID. + :param client_secret: Client secret. + :param domain: Optional domain to limit authorization to. + :param scope: Scope. + """ + + self.domain = domain + + super(GoogleOAuth2Provider, self).__init__( + client_id=client_id, + client_secret=client_secret, + scope=scope, + auth_uri=GOOGLE_AUTH_URI, + token_uri=GOOGLE_TOKEN_URI, + revoke_uri=GOOGLE_REVOKE_URI + ) + + @classmethod + def from_app_config(cls, config): + client_id = config.get('GOOGLE_CLIENT_ID') + client_secret = config.get('GOOGLE_CLIENT_SECRET') + domain = config.get('GOOGLE_TEAM_ID') + + if not client_id or not client_secret: + raise ProviderConfigurationError( + 'Google authentication requires a client ID ' + '(GOOGLE_CLIENT_ID) and secret (GOOGLE_CLIENT_SECRET) to be ' + 'provided' + ) + + if not domain: + warnings.warn( + 'No domain provided for Google authentication (GOOGLE_DOMAIN) ' + '- any user with a Google account can authenticate' + ) + + return cls(client_id=client_id, + client_secret=client_secret, + domain=domain) + + def get_flow(self, redirect_uri=None): + # XXX(dcramer): we have to generate this each request because + # oauth2client doesn't want you to set redirect_uri as part of the + # request, which causes a lot of runtime issues. + auth_uri = self.auth_uri + if self.domain: + auth_uri = auth_uri + '?hd=' + self.domain + + return OAuth2WebServerFlow( + client_id=self.client_id, + client_secret=self.client_secret, + scope=self.scope, + redirect_uri=redirect_uri, + user_agent='freight/{0} (python {1})'.format( + freight.VERSION, + PYTHON_VERSION, + ), + auth_uri=auth_uri, + token_uri=self.token_uri, + revoke_uri=self.revoke_uri + ) + + def step2_exchange(self, code, redirect_uri=None): + resp = super(GoogleOAuth2Provider, self).step2_exchange(code, + redirect_uri) + + # Validate the domain. + if self.domain and resp.id_token.get('hd') != self.domain: + raise AccessDeniedError('domain %s does not match %s' % + (resp.id_token.get('hd'), self.domain)) + + return resp + + +class GitHubOAuth2Provider(OAuth2Provider): + """GitHub OAuth 2.0 provider. + """ + + def __init__(self, + client_id, + client_secret, + team_id=None, + organization_id=None, + scope='user'): + """Initialize a GitHub OAuth 2.0 provider. + + :param client_id: Client ID. + :param client_secret: Client secret. + :param team_id: Optional team ID to limit authorization to. + :type team_id: :class:`int`, :class:`long` + :param organization_id: + Optional organization ID to limit authorization to. + :type organization_id: :class:`int`, :class:`long` + :param scope: Scope. Default ``user``. + """ + + self.team_id = team_id + self.organization_id = organization_id + + super(GitHubOAuth2Provider, self).__init__( + client_id=client_id, + client_secret=client_secret, + scope=scope, + auth_uri=GITHUB_AUTH_URI, + token_uri=GITHUB_TOKEN_URI + ) + + @classmethod + def from_app_config(cls, config): + client_id = config.get('GITHUB_CLIENT_ID') + client_secret = config.get('GITHUB_CLIENT_SECRET') + team_id = config.get('GITHUB_TEAM_ID') + organization_id = config.get('GITHUB_ORGANIZATION_ID') + + if not client_id or not client_secret: + raise ProviderConfigurationError( + 'GitHub authentication requires a client ID ' + '(GITHUB_CLIENT_ID) and secret (GITHUB_CLIENT_SECRET) to be ' + 'provided' + ) + + if team_id: + try: + team_id = int(team_id) + except ValueError: + raise ProviderConfigurationError( + 'invalid team ID (GITHUB_TEAM_ID): %s' % (team_id) + ) + elif organization_id: + try: + organization_id = int(organization_id) + except ValueError: + raise ProviderConfigurationError( + 'invalid organization ID (GITHUB_ORGANIZATION_ID): %s' % + (organization_id) + ) + + raise ProviderConfigurationError( + 'either a team ID (GITHUB_TEAM_ID) or an organization ID ' + '(GITHUB_ORGANIZATION_ID) must be configured for GitHub ' + 'authentication' + ) + + return cls(client_id=client_id, + client_secret=client_secret, + team_id=team_id, + organization_id=organization_id) + + def _get_github_api_json(self, url, access_token): + """Perform a GET request against the GitHub API. + + :param url: URL. + :param access_token: Access token. + :returns: the JSON response body on success. + """ + + resp = requests.get(url, params={'access_token': access_token}) + + logger.debug('Response for %s: status code %d' % (url, + resp.status_code)) + + if resp.status_code == 200: + return resp.json() + elif resp.status_code == 404: + # GitHub will return 404 if the scope does not allow access to + # the requested endpoint. For now, let's raise a FlowExchangeError + # to force re-authorization, hopefully with the right scope. + raise FlowExchangeError('insufficient_scope') + + # Any other error is hard to deal with. Let's raise it and propagate + # the exception to, say, Sentry. + resp.raise_for_status() + + def step1_get_authorize_url(self, redirect_uri=None): + return self._get_flow(redirect_uri).step1_get_authorize_url() + + def step2_exchange(self, code, redirect_uri=None): + resp = super(GitHubOAuth2Provider, self).step2_exchange(code, + redirect_uri) + + # Fetch the user's profile information. + id_token_additions = {} + + user_json = self._get_github_api_json(GITHUB_API_USER_URI, + resp.access_token) + + id_token_additions['email'] = user_json['email'] + id_token_additions['login'] = user_json['login'] + id_token_additions['id'] = user_json['id'] + + # Fetch the user's teams. + teams_json = self._get_github_api_json(GITHUB_API_USER_TEAMS_URI, + resp.access_token) + + team_ids = set() + organization_ids = set() + + for team in teams_json: + team_ids.add(team['id']) + organization_ids.add(team['organization']['id']) + + id_token_additions['team_ids'] = team_ids + id_token_additions['organization_ids'] = organization_ids + + # Validate that the user should be permitted access. + if (self.team_id and int(self.team_id) not in team_ids) or \ + (self.organization_id and + int(self.organization_id) not in organization_ids): + raise AccessDeniedError() + + # Update the response. + if resp.id_token: + resp.id_token.update(id_token_additions) + else: + resp.id_token = id_token_additions + + return resp diff --git a/freight/config.py b/freight/config.py index b38f6dbc..2c28b5a9 100644 --- a/freight/config.py +++ b/freight/config.py @@ -14,6 +14,7 @@ from freight.api.controller import ApiController from freight.constants import PROJECT_ROOT from freight.utils.celery import ContextualCelery +from freight.auth import Auth api = ApiController(prefix='/api/0') @@ -22,6 +23,7 @@ heroku = Heroku() redis = Redis() sentry = Sentry(logging=True, level=logging.WARN) +auth = Auth() def configure_logging(app): @@ -66,13 +68,6 @@ def create_app(_read_config=True, **config): # Currently authentication defaults to Google. app.config['AUTH_BACKEND'] = os.environ.get('AUTH_BACKEND', 'google') - app.config['GOOGLE_CLIENT_ID'] = os.environ.get('GOOGLE_CLIENT_ID') - app.config['GOOGLE_CLIENT_SECRET'] = os.environ.get('GOOGLE_CLIENT_SECRET') - app.config['GOOGLE_DOMAIN'] = os.environ.get('GOOGLE_DOMAIN') - app.config['GITHUB_CLIENT_ID'] = os.environ.get('GITHUB_CLIENT_ID') - app.config['GITHUB_CLIENT_SECRET'] = os.environ.get('GITHUB_CLIENT_SECRET') - app.config['GITHUB_TEAM_ID'] = os.environ.get('GITHUB_TEAM_ID') - app.config['GITHUB_ORGANIZATION_ID'] = os.environ.get('GITHUB_ORGANIZATION_ID') # Generate a GitHub token via Curl: # curlish https://api.github.com/authorizations \ @@ -202,35 +197,7 @@ def configure_api(app): def configure_auth(app): - if not app.config['AUTH_BACKEND']: - app.config['AUTH_BACKEND'] = 'google' - elif app.config['AUTH_BACKEND'] not in {'google', 'github'}: - raise RuntimeError('invalid authentication backend: %s' % - (app.config['AUTH_BACKEND'])) - - if app.config['AUTH_BACKEND'] == 'github': - if not app.config['GITHUB_CLIENT_ID'] or \ - not app.config['GITHUB_CLIENT_SECRET']: - raise RuntimeError('GitHub authentication requires a client ID ' - 'and secret to be provided') - - if app.config['GITHUB_TEAM_ID']: - try: - app.config['GITHUB_TEAM_ID'] = \ - long(app.config['GITHUB_TEAM_ID']) - except ValueError: - raise RuntimeError('invalid team ID: %s' % - (app.config['GITHUB_TEAM_ID'])) - elif app.config['GITHUB_ORGANIZATION_ID']: - try: - app.config['GITHUB_ORGANIZATION_ID'] = \ - long(app.config['GITHUB_ORGANIZATION_ID']) - except ValueError: - raise RuntimeError('invalid organization ID: %s' % - (app.config['GITHUB_ORGANIZATION_ID'])) - else: - raise RuntimeError('either a team or an organization ID must be ' - 'configured for GitHub authentication') + auth.init_app(app) def configure_celery(app): diff --git a/freight/web/auth.py b/freight/web/auth.py index d94b821f..f676da46 100644 --- a/freight/web/auth.py +++ b/freight/web/auth.py @@ -1,17 +1,16 @@ from __future__ import absolute_import -import freight import logging -import requests from flask import current_app, redirect, request, session, url_for, abort from flask.views import MethodView -from oauth2client.client import OAuth2WebServerFlow, FlowExchangeError +from oauth2client.client import FlowExchangeError +from freight.auth import AccessDeniedError from freight.config import db -from freight.constants import PYTHON_VERSION from freight.models import User + GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/auth' GOOGLE_REVOKE_URI = 'https://accounts.google.com/o/oauth2/revoke' GOOGLE_TOKEN_URI = 'https://accounts.google.com/o/oauth2/token' @@ -25,279 +24,6 @@ logger = logging.getLogger(__name__) -class AccessDeniedError(Exception): - """Access denied. - - Raised if authentication with the backend succeeded but the user is not - allowed access by configuration. - """ - - pass - - -class OAuth2Provider(object): - """OAuth 2.0 provider. - """ - - def __init__(self, - client_id, - client_secret, - scope, - auth_uri, - token_uri, - revoke_uri=None): - """Initialize an OAuth 2.0 provider. - - :param client_id: Client ID. - :param client_secret: Client secret. - :param scope: Scope. - :param auth_uri: Authentication URI. - :param token_uri: Token exchange URI. - :param revoke_uri: Token revocation URI. - """ - - self.client_id = client_id - self.client_secret = client_secret - self.scope = scope - self.auth_uri = auth_uri - self.token_uri = token_uri - self.revoke_uri = revoke_uri - - def _get_flow(self, redirect_uri=None): - """Get authorization flow. - - :param redirect_uri: Redirect URI. - :rtype: :class:`oauth2client.client.OAuth2WebServerFlow` - """ - - return OAuth2WebServerFlow( - client_id=self.client_id, - client_secret=self.client_secret, - scope=self.scope, - redirect_uri=redirect_uri, - user_agent='freight/{0} (python {1})'.format( - freight.VERSION, - PYTHON_VERSION, - ), - auth_uri=self.auth_uri, - token_uri=self.token_uri, - revoke_uri=self.revoke_uri - ) - - def step1_get_authorize_url(self, redirect_uri=None): - """Get provider authorize URL. - - :param redirect_uri: Optional redirect URI. - :returns: the authorize URL For the provider to redirect the user to. - """ - - return self._get_flow(redirect_uri).step1_get_authorize_url() - - def step2_exchange(self, code, redirect_uri=None): - """Exchange an authorization code for an access token. - - :param code: Authorization code. - :param redirect_uri: Redirect URI. - :returns: the exchange result. - :rtype: :class:`oauth2client.client.OAuth2Credentials` - :raises AccessDeniedError: - if the authenticated user does not have access by the configured - restrictions. - :raises oauth2client.client.FlowExchangeError: - if an error occured during the exchange, most likely due to an - already used code or a bad scope. - """ - - return self._get_flow(redirect_uri).step2_exchange(code) - - -class GitHubOAuth2Provider(OAuth2Provider): - """GitHub OAuth 2.0 provider. - """ - - def __init__(self, - client_id, - client_secret, - team_id=None, - organization_id=None, - scope='user'): - """Initialize a GitHub OAuth 2.0 provider. - - :param client_id: Client ID. - :param client_secret: Client secret. - :param team_id: Optional team ID to limit authorization to. - :type team_id: :class:`int`, :class:`long` - :param organization_id: - Optional organization ID to limit authorization to. - :type organization_id: :class:`int`, :class:`long` - :param scope: Scope. Default ``user``. - """ - - self.team_id = team_id - self.organization_id = organization_id - - super(GitHubOAuth2Provider, self).__init__( - client_id=client_id, - client_secret=client_secret, - scope=scope, - auth_uri=GITHUB_AUTH_URI, - token_uri=GITHUB_TOKEN_URI - ) - - def _get_github_api_json(self, url, access_token): - """Perform a GET request against the GitHub API. - - :param url: URL. - :param access_token: Access token. - :returns: the JSON response body on success. - """ - - resp = requests.get(url, params={'access_token': access_token}) - - if resp.status_code == 200: - return resp.json() - elif resp.status_code == 404: - # GitHub will return 404 if the scope does not allow access to - # the requested endpoint. For now, let's raise a FlowExchangeError - # to force re-authorization, hopefully with the right scope. - raise FlowExchangeError('insufficient_scope') - - # Any other error is hard to deal with. Let's raise it and propagate - # the exception to, say, Sentry. - resp.raise_for_status() - - def step1_get_authorize_url(self, redirect_uri=None): - return self._get_flow(redirect_uri).step1_get_authorize_url() - - def step2_exchange(self, code, redirect_uri=None): - resp = super(GitHubOAuth2Provider, self).step2_exchange(code, - redirect_uri) - - # Fetch the user's profile information. - id_token_additions = {} - - user_json = self._get_github_api_json(GITHUB_API_USER_URI, - resp.access_token) - - id_token_additions['email'] = user_json['email'] - id_token_additions['login'] = user_json['login'] - id_token_additions['id'] = user_json['id'] - - # Fetch the user's teams. - teams_json = self._get_github_api_json(GITHUB_API_USER_TEAMS_URI, - resp.access_token) - - team_ids = set() - organization_ids = set() - - for team in teams_json: - team_ids.add(team['id']) - organization_ids.add(team['organization']['id']) - - id_token_additions['team_ids'] = team_ids - id_token_additions['organization_ids'] = organization_ids - - # Validate that the user should be permitted access. - if (self.team_id and self.team_id not in team_ids) or \ - (self.organization_id and - self.organization_id not in organization_ids): - raise AccessDeniedError() - - # Update the response. - if resp.id_token: - resp.id_token.update(id_token_additions) - else: - resp.id_token = id_token_additions - - return resp - - -class GoogleOAuth2Provider(OAuth2Provider): - """Google OAuth 2.0 privder. - """ - - def __init__(self, - client_id, - client_secret, - domain=None, - scope='https://www.googleapis.com/auth/userinfo.email'): - """Initialize a Google OAuth 2.0 provider. - - :param client_id: Client ID. - :param client_secret: Client secret. - :param domain: Optional domain to limit authorization to. - :param scope: Scope. - """ - - self.domain = domain - - super(GoogleOAuth2Provider, self).__init__( - client_id=client_id, - client_secret=client_secret, - scope=scope, - auth_uri=GOOGLE_AUTH_URI, - token_uri=GOOGLE_TOKEN_URI, - revoke_uri=GOOGLE_REVOKE_URI - ) - - def get_flow(self, redirect_uri=None): - # XXX(dcramer): we have to generate this each request because - # oauth2client doesn't want you to set redirect_uri as part of the - # request, which causes a lot of runtime issues. - auth_uri = self.auth_uri - if self.domain: - auth_uri = auth_uri + '?hd=' + self.domain - - return OAuth2WebServerFlow( - client_id=self.client_id, - client_secret=self.client_secret, - scope=self.scope, - redirect_uri=redirect_uri, - user_agent='freight/{0} (python {1})'.format( - freight.VERSION, - PYTHON_VERSION, - ), - auth_uri=auth_uri, - token_uri=self.token_uri, - revoke_uri=self.revoke_uri - ) - - def step2_exchange(self, code, redirect_uri=None): - resp = super(GoogleOAuth2Provider, self).step2_exchange(code, - redirect_uri) - - # Validate the domain. - if self.domain and resp.id_token.get('hd') != self.domain: - raise AccessDeniedError('domain %s does not match %s' % - (resp.id_token.get('hd'), self.domain)) - - return resp - - -def setup_github_provider(config): - return GitHubOAuth2Provider( - client_id=config['GITHUB_CLIENT_ID'], - client_secret=config['GITHUB_CLIENT_SECRET'], - team_id=config['GITHUB_TEAM_ID'], - organization_id=config['GITHUB_ORGANIZATION_ID'] - ) - - -def setup_google_provider(config): - return GoogleOAuth2Provider( - client_id=config['GOOGLE_CLIENT_ID'], - client_secret=config['GOOGLE_CLIENT_SECRET'], - domain=config['GOOGLE_DOMAIN'], - ) - - -def get_auth_provider(): - return { - 'github': setup_github_provider, - 'google': setup_google_provider, - }[current_app.config['AUTH_BACKEND']](current_app.config) - - class LoginView(MethodView): def __init__(self, authorized_url): self.authorized_url = authorized_url @@ -305,7 +31,7 @@ def __init__(self, authorized_url): def get(self): redirect_uri = url_for(self.authorized_url, _external=True) - provider = get_auth_provider() + provider = current_app.state['auth_provider'] auth_uri = provider.step1_get_authorize_url(redirect_uri=redirect_uri) return redirect(auth_uri) @@ -318,7 +44,7 @@ def __init__(self, complete_url, authorized_url): def get(self): redirect_uri = url_for(self.authorized_url, _external=True) - provider = get_auth_provider() + provider = current_app.state['auth_provider'] try: resp = provider.step2_exchange(request.args['code'], redirect_uri) From 5f44e455873b80f2aaba6c107307e22c497c82f3 Mon Sep 17 00:00:00 2001 From: Nick Bruun Date: Sat, 11 Jul 2015 13:15:22 +0200 Subject: [PATCH 7/7] Update to imports. --- freight/auth/__init__.py | 2 ++ freight/auth/exceptions.py | 3 +++ freight/auth/providers.py | 9 ++++++--- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/freight/auth/__init__.py b/freight/auth/__init__.py index e13f0f21..77f7be0b 100644 --- a/freight/auth/__init__.py +++ b/freight/auth/__init__.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + from .exceptions import AccessDeniedError, ProviderConfigurationError # noqa from .providers import GoogleOAuth2Provider, GitHubOAuth2Provider diff --git a/freight/auth/exceptions.py b/freight/auth/exceptions.py index 5b6755ca..5c977136 100644 --- a/freight/auth/exceptions.py +++ b/freight/auth/exceptions.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import + + class AccessDeniedError(Exception): """Access denied. diff --git a/freight/auth/providers.py b/freight/auth/providers.py index ccca199d..ef473ae9 100644 --- a/freight/auth/providers.py +++ b/freight/auth/providers.py @@ -1,11 +1,14 @@ -import freight +from __future__ import absolute_import + import logging -import requests import warnings -from freight.constants import PYTHON_VERSION +import requests from oauth2client.client import OAuth2WebServerFlow, FlowExchangeError +import freight +from freight.constants import PYTHON_VERSION + from .exceptions import AccessDeniedError, ProviderConfigurationError