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) diff --git a/freight/auth/__init__.py b/freight/auth/__init__.py new file mode 100644 index 00000000..77f7be0b --- /dev/null +++ b/freight/auth/__init__.py @@ -0,0 +1,39 @@ +from __future__ import absolute_import + +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..5c977136 --- /dev/null +++ b/freight/auth/exceptions.py @@ -0,0 +1,18 @@ +from __future__ import absolute_import + + +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..ef473ae9 --- /dev/null +++ b/freight/auth/providers.py @@ -0,0 +1,345 @@ +from __future__ import absolute_import + +import logging +import warnings + +import requests +from oauth2client.client import OAuth2WebServerFlow, FlowExchangeError + +import freight +from freight.constants import PYTHON_VERSION + +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 12179f7f..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): @@ -64,10 +66,8 @@ 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 - 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') + # Currently authentication defaults to Google. + app.config['AUTH_BACKEND'] = os.environ.get('AUTH_BACKEND', 'google') # Generate a GitHub token via Curl: # curlish https://api.github.com/authorizations \ @@ -160,6 +160,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 +196,10 @@ def configure_api(app): api.init_app(app) +def configure_auth(app): + auth.init_app(app) + + def configure_celery(app): celery.init_app(app) diff --git a/freight/web/auth.py b/freight/web/auth.py index d3fb8e1e..f676da46 100644 --- a/freight/web/auth.py +++ b/freight/web/auth.py @@ -1,41 +1,27 @@ from __future__ import absolute_import -import freight +import logging -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 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' +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' + -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, - ) +logger = logging.getLogger(__name__) class LoginView(MethodView): @@ -45,8 +31,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 = current_app.state['auth_provider'] + auth_uri = provider.step1_get_authorize_url(redirect_uri=redirect_uri) return redirect(auth_uri) @@ -58,13 +44,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) - resp = flow.step2_exchange(request.args['code']) - - if 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)) + provider = current_app.state['auth_provider'] + + try: + 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. + 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'],