2017-08-23 17:58:52 +02:00
|
|
|
import abc
|
2017-07-25 17:50:22 +02:00
|
|
|
import json
|
2017-08-25 12:35:08 +02:00
|
|
|
import logging
|
2018-09-11 15:36:25 +02:00
|
|
|
import typing
|
2017-07-25 17:50:22 +02:00
|
|
|
|
2018-09-11 15:36:25 +02:00
|
|
|
import attr
|
2017-07-25 17:50:22 +02:00
|
|
|
from rauth import OAuth2Service
|
2017-08-24 12:38:43 +02:00
|
|
|
from flask import current_app, url_for, request, redirect, session, Response
|
2017-07-25 17:50:22 +02:00
|
|
|
|
|
|
|
|
2017-08-23 17:58:52 +02:00
|
|
|
@attr.s
|
|
|
|
class OAuthUserResponse:
|
|
|
|
"""Represents user information requested to an OAuth provider after
|
|
|
|
authenticating.
|
|
|
|
"""
|
|
|
|
|
|
|
|
id = attr.ib(validator=attr.validators.instance_of(str))
|
|
|
|
email = attr.ib(validator=attr.validators.instance_of(str))
|
2018-09-11 15:36:25 +02:00
|
|
|
access_token = attr.ib(validator=attr.validators.instance_of(str))
|
|
|
|
scopes: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list))
|
2017-08-23 17:58:52 +02:00
|
|
|
|
|
|
|
|
2017-08-25 10:32:04 +02:00
|
|
|
class OAuthError(Exception):
|
|
|
|
"""Superclass of all exceptions raised by this module."""
|
|
|
|
|
|
|
|
|
|
|
|
class ProviderConfigurationMissing(OAuthError):
|
2017-08-24 12:38:43 +02:00
|
|
|
"""Raised when an OAuth provider is used but not configured."""
|
|
|
|
|
|
|
|
|
2017-08-25 10:32:04 +02:00
|
|
|
class ProviderNotImplemented(OAuthError):
|
2017-08-24 12:38:43 +02:00
|
|
|
"""Raised when a provider is requested that does not exist."""
|
|
|
|
|
|
|
|
|
2017-08-25 10:32:04 +02:00
|
|
|
class OAuthCodeNotProvided(OAuthError):
|
|
|
|
"""Raised when the 'code' arg is not provided in the OAuth callback."""
|
|
|
|
|
|
|
|
|
2017-08-25 12:35:08 +02:00
|
|
|
class ProviderNotConfigured:
|
|
|
|
"""Dummy class that indicates a provider isn't configured."""
|
|
|
|
|
|
|
|
|
2017-08-23 17:58:52 +02:00
|
|
|
class OAuthSignIn(metaclass=abc.ABCMeta):
|
2017-08-25 12:35:08 +02:00
|
|
|
provider_name: str = None # set in each subclass.
|
|
|
|
|
2017-08-24 12:38:43 +02:00
|
|
|
_providers = None # initialized in get_provider()
|
2017-08-25 12:35:08 +02:00
|
|
|
_log = logging.getLogger(f'{__name__}.OAuthSignIn')
|
2017-07-25 17:50:22 +02:00
|
|
|
|
2017-08-25 12:35:08 +02:00
|
|
|
def __init__(self):
|
|
|
|
credentials = current_app.config['OAUTH_CREDENTIALS'].get(self.provider_name)
|
2017-08-25 10:32:04 +02:00
|
|
|
if not credentials:
|
2017-08-25 12:35:08 +02:00
|
|
|
raise ProviderConfigurationMissing(
|
|
|
|
f'Missing OAuth credentials for {self.provider_name}')
|
|
|
|
|
2017-07-25 17:50:22 +02:00
|
|
|
self.consumer_id = credentials['id']
|
|
|
|
self.consumer_secret = credentials['secret']
|
|
|
|
|
2017-08-25 10:32:04 +02:00
|
|
|
# Set in a subclass
|
|
|
|
self.service: OAuth2Service = None
|
|
|
|
|
2017-08-23 17:58:52 +02:00
|
|
|
@abc.abstractmethod
|
2017-08-24 12:38:43 +02:00
|
|
|
def authorize(self) -> Response:
|
|
|
|
"""Redirect to the correct authorization endpoint for the current provider.
|
2017-08-23 17:58:52 +02:00
|
|
|
|
|
|
|
Depending on the provider, we sometimes have to specify a different
|
|
|
|
'scope'.
|
|
|
|
"""
|
2017-07-25 17:50:22 +02:00
|
|
|
pass
|
|
|
|
|
2017-08-23 17:58:52 +02:00
|
|
|
@abc.abstractmethod
|
|
|
|
def callback(self) -> OAuthUserResponse:
|
2017-08-24 12:38:43 +02:00
|
|
|
"""Callback performed after authorizing the user.
|
2017-08-23 17:58:52 +02:00
|
|
|
|
|
|
|
This is usually a request to a protected /me endpoint to query for
|
|
|
|
user information, such as user id and email address.
|
|
|
|
"""
|
2017-07-25 17:50:22 +02:00
|
|
|
pass
|
|
|
|
|
|
|
|
def get_callback_url(self):
|
2017-09-01 16:19:58 +02:00
|
|
|
return url_for('users.oauth_callback', provider=self.provider_name,
|
|
|
|
_external=True, _scheme=current_app.config['SCHEME'])
|
2017-07-25 17:50:22 +02:00
|
|
|
|
2017-08-25 10:32:04 +02:00
|
|
|
@staticmethod
|
|
|
|
def auth_code_from_request() -> str:
|
|
|
|
try:
|
|
|
|
return request.args['code']
|
|
|
|
except KeyError:
|
|
|
|
raise OAuthCodeNotProvided('A code argument was not provided in the request')
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def decode_json(payload):
|
|
|
|
return json.loads(payload.decode('utf-8'))
|
|
|
|
|
|
|
|
def make_oauth_session(self):
|
|
|
|
return self.service.get_auth_session(
|
|
|
|
data={'code': self.auth_code_from_request(),
|
|
|
|
'grant_type': 'authorization_code',
|
|
|
|
'redirect_uri': self.get_callback_url()},
|
|
|
|
decoder=self.decode_json
|
|
|
|
)
|
|
|
|
|
2017-07-25 17:50:22 +02:00
|
|
|
@classmethod
|
2017-08-24 12:38:43 +02:00
|
|
|
def get_provider(cls, provider_name) -> 'OAuthSignIn':
|
|
|
|
if cls._providers is None:
|
2017-08-25 12:35:08 +02:00
|
|
|
cls._init_providers()
|
|
|
|
|
2017-08-24 12:38:43 +02:00
|
|
|
try:
|
2017-08-25 12:35:08 +02:00
|
|
|
provider = cls._providers[provider_name]
|
2017-08-24 12:38:43 +02:00
|
|
|
except KeyError:
|
|
|
|
raise ProviderNotImplemented(f'No such OAuth provider {provider_name}')
|
2017-07-25 17:50:22 +02:00
|
|
|
|
2017-08-25 12:35:08 +02:00
|
|
|
if provider is ProviderNotConfigured:
|
|
|
|
raise ProviderConfigurationMissing(f'OAuth provider {provider_name} not configured')
|
|
|
|
|
|
|
|
return provider
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def _init_providers(cls):
|
|
|
|
cls._providers = {}
|
|
|
|
|
|
|
|
for provider_class in cls.__subclasses__():
|
|
|
|
try:
|
|
|
|
provider = provider_class()
|
|
|
|
except ProviderConfigurationMissing:
|
|
|
|
cls._log.info('OAuth provider %s not configured',
|
|
|
|
provider_class.provider_name)
|
|
|
|
provider = ProviderNotConfigured
|
|
|
|
cls._providers[provider_class.provider_name] = provider
|
|
|
|
|
2017-07-25 17:50:22 +02:00
|
|
|
|
2017-07-27 23:31:26 +02:00
|
|
|
class BlenderIdSignIn(OAuthSignIn):
|
2017-08-25 12:35:08 +02:00
|
|
|
provider_name = 'blender-id'
|
2018-09-11 15:36:25 +02:00
|
|
|
scopes = ['email', 'badge']
|
2017-08-25 12:35:08 +02:00
|
|
|
|
2017-07-27 23:31:26 +02:00
|
|
|
def __init__(self):
|
2018-08-29 14:17:17 +02:00
|
|
|
from urllib.parse import urljoin
|
2017-08-25 12:35:08 +02:00
|
|
|
super().__init__()
|
2017-07-27 23:31:26 +02:00
|
|
|
|
2018-06-22 19:38:27 +02:00
|
|
|
base_url = current_app.config['BLENDER_ID_ENDPOINT']
|
2017-07-27 23:31:26 +02:00
|
|
|
|
|
|
|
self.service = OAuth2Service(
|
|
|
|
name='blender-id',
|
|
|
|
client_id=self.consumer_id,
|
|
|
|
client_secret=self.consumer_secret,
|
2018-08-29 14:17:17 +02:00
|
|
|
authorize_url=urljoin(base_url, 'oauth/authorize'),
|
|
|
|
access_token_url=urljoin(base_url, 'oauth/token'),
|
2018-09-11 17:53:23 +02:00
|
|
|
base_url=urljoin(base_url, 'api/'),
|
2017-07-27 23:31:26 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
def authorize(self):
|
|
|
|
return redirect(self.service.get_authorize_url(
|
2018-09-11 15:36:25 +02:00
|
|
|
scope=' '.join(self.scopes),
|
2017-07-27 23:31:26 +02:00
|
|
|
response_type='code',
|
|
|
|
redirect_uri=self.get_callback_url())
|
|
|
|
)
|
|
|
|
|
|
|
|
def callback(self):
|
2017-08-25 10:32:04 +02:00
|
|
|
oauth_session = self.make_oauth_session()
|
2017-08-24 12:38:43 +02:00
|
|
|
|
2017-07-27 23:31:26 +02:00
|
|
|
# TODO handle exception for failed oauth or not authorized
|
2017-10-17 12:16:20 +02:00
|
|
|
access_token = oauth_session.access_token
|
|
|
|
assert isinstance(access_token, str), f'oauth token must be str, not {type(access_token)}'
|
|
|
|
|
|
|
|
session['blender_id_oauth_token'] = access_token
|
2017-08-23 17:58:52 +02:00
|
|
|
me = oauth_session.get('user').json()
|
2018-09-11 15:36:25 +02:00
|
|
|
|
|
|
|
# Blender ID doesn't tell us which scopes were granted by the user, so
|
|
|
|
# for now assume we got all the scopes we requested.
|
|
|
|
# (see https://github.com/jazzband/django-oauth-toolkit/issues/644)
|
|
|
|
return OAuthUserResponse(str(me['id']), me['email'], access_token, self.scopes)
|
2017-07-27 23:31:26 +02:00
|
|
|
|
|
|
|
|
2017-07-25 17:50:22 +02:00
|
|
|
class FacebookSignIn(OAuthSignIn):
|
2017-08-25 12:35:08 +02:00
|
|
|
provider_name = 'facebook'
|
|
|
|
|
2017-07-25 17:50:22 +02:00
|
|
|
def __init__(self):
|
2017-08-25 12:35:08 +02:00
|
|
|
super().__init__()
|
2017-07-25 17:50:22 +02:00
|
|
|
self.service = OAuth2Service(
|
|
|
|
name='facebook',
|
|
|
|
client_id=self.consumer_id,
|
|
|
|
client_secret=self.consumer_secret,
|
|
|
|
authorize_url='https://graph.facebook.com/oauth/authorize',
|
|
|
|
access_token_url='https://graph.facebook.com/oauth/access_token',
|
|
|
|
base_url='https://graph.facebook.com/'
|
|
|
|
)
|
|
|
|
|
|
|
|
def authorize(self):
|
|
|
|
return redirect(self.service.get_authorize_url(
|
|
|
|
scope='email',
|
|
|
|
response_type='code',
|
|
|
|
redirect_uri=self.get_callback_url())
|
|
|
|
)
|
|
|
|
|
|
|
|
def callback(self):
|
2017-08-25 10:32:04 +02:00
|
|
|
oauth_session = self.make_oauth_session()
|
2017-07-25 17:50:22 +02:00
|
|
|
|
|
|
|
me = oauth_session.get('me?fields=id,email').json()
|
|
|
|
# TODO handle case when user chooses not to disclose en email
|
2017-08-23 17:58:52 +02:00
|
|
|
# see https://developers.facebook.com/docs/graph-api/reference/user/
|
2018-09-11 15:36:25 +02:00
|
|
|
return OAuthUserResponse(me['id'], me.get('email'), '', [])
|
2017-07-27 23:31:26 +02:00
|
|
|
|
|
|
|
|
|
|
|
class GoogleSignIn(OAuthSignIn):
|
2017-08-25 12:35:08 +02:00
|
|
|
provider_name = 'google'
|
|
|
|
|
2017-07-27 23:31:26 +02:00
|
|
|
def __init__(self):
|
2017-08-25 12:35:08 +02:00
|
|
|
super().__init__()
|
2017-07-27 23:31:26 +02:00
|
|
|
self.service = OAuth2Service(
|
|
|
|
name='google',
|
|
|
|
client_id=self.consumer_id,
|
|
|
|
client_secret=self.consumer_secret,
|
|
|
|
authorize_url='https://accounts.google.com/o/oauth2/auth',
|
|
|
|
access_token_url='https://accounts.google.com/o/oauth2/token',
|
|
|
|
base_url='https://www.googleapis.com/oauth2/v1/'
|
|
|
|
)
|
|
|
|
|
|
|
|
def authorize(self):
|
|
|
|
return redirect(self.service.get_authorize_url(
|
|
|
|
scope='https://www.googleapis.com/auth/userinfo.email',
|
|
|
|
response_type='code',
|
|
|
|
redirect_uri=self.get_callback_url())
|
|
|
|
)
|
|
|
|
|
|
|
|
def callback(self):
|
2017-08-25 10:32:04 +02:00
|
|
|
oauth_session = self.make_oauth_session()
|
2017-07-27 23:31:26 +02:00
|
|
|
|
|
|
|
me = oauth_session.get('userinfo').json()
|
2018-09-11 15:36:25 +02:00
|
|
|
return OAuthUserResponse(str(me['id']), me['email'], '', [])
|