diff --git a/pillar/api/eve_settings.py b/pillar/api/eve_settings.py index a39caf3b..65a8a471 100644 --- a/pillar/api/eve_settings.py +++ b/pillar/api/eve_settings.py @@ -391,6 +391,13 @@ tokens_schema = { 'type': 'string', }, }, + + # OAuth scopes granted to this token. + 'oauth_scopes': { + 'type': 'list', + 'default': [], + 'schema': {'type': 'string'}, + } } files_schema = { diff --git a/pillar/api/utils/authentication.py b/pillar/api/utils/authentication.py index 9d28cf95..3940b621 100644 --- a/pillar/api/utils/authentication.py +++ b/pillar/api/utils/authentication.py @@ -235,8 +235,14 @@ def hash_auth_token(token: str) -> str: return base64.b64encode(digest).decode('ascii') -def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False, - org_roles: typing.Set[str] = frozenset()): +def store_token(user_id, + token: str, + token_expiry, + oauth_subclient_id=False, + *, + org_roles: typing.Set[str] = frozenset(), + oauth_scopes: typing.Optional[typing.List[str]] = None, + ): """Stores an authentication token. :returns: the token document from MongoDB @@ -253,6 +259,8 @@ def store_token(user_id, token: str, token_expiry, oauth_subclient_id=False, token_data['is_subclient_token'] = True if org_roles: token_data['org_roles'] = sorted(org_roles) + if oauth_scopes: + token_data['oauth_scopes'] = oauth_scopes r, _, _, status = current_app.post_internal('tokens', token_data) diff --git a/pillar/auth/oauth.py b/pillar/auth/oauth.py index 9ab49180..b7f1f1a8 100644 --- a/pillar/auth/oauth.py +++ b/pillar/auth/oauth.py @@ -1,8 +1,9 @@ import abc -import attr import json import logging +import typing +import attr from rauth import OAuth2Service from flask import current_app, url_for, request, redirect, session, Response @@ -15,6 +16,8 @@ class OAuthUserResponse: id = attr.ib(validator=attr.validators.instance_of(str)) email = attr.ib(validator=attr.validators.instance_of(str)) + access_token = attr.ib(validator=attr.validators.instance_of(str)) + scopes: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list)) class OAuthError(Exception): @@ -127,6 +130,7 @@ class OAuthSignIn(metaclass=abc.ABCMeta): class BlenderIdSignIn(OAuthSignIn): provider_name = 'blender-id' + scopes = ['email', 'badge'] def __init__(self): from urllib.parse import urljoin @@ -145,7 +149,7 @@ class BlenderIdSignIn(OAuthSignIn): def authorize(self): return redirect(self.service.get_authorize_url( - scope='email', + scope=' '.join(self.scopes), response_type='code', redirect_uri=self.get_callback_url()) ) @@ -159,7 +163,11 @@ class BlenderIdSignIn(OAuthSignIn): session['blender_id_oauth_token'] = access_token me = oauth_session.get('user').json() - return OAuthUserResponse(str(me['id']), me['email']) + + # 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) class FacebookSignIn(OAuthSignIn): @@ -189,7 +197,7 @@ class FacebookSignIn(OAuthSignIn): me = oauth_session.get('me?fields=id,email').json() # TODO handle case when user chooses not to disclose en email # see https://developers.facebook.com/docs/graph-api/reference/user/ - return OAuthUserResponse(me['id'], me.get('email')) + return OAuthUserResponse(me['id'], me.get('email'), '', []) class GoogleSignIn(OAuthSignIn): @@ -217,4 +225,4 @@ class GoogleSignIn(OAuthSignIn): oauth_session = self.make_oauth_session() me = oauth_session.get('userinfo').json() - return OAuthUserResponse(str(me['id']), me['email']) + return OAuthUserResponse(str(me['id']), me['email'], '', []) diff --git a/pillar/web/users/routes.py b/pillar/web/users/routes.py index 44f9ccd2..b4162207 100644 --- a/pillar/web/users/routes.py +++ b/pillar/web/users/routes.py @@ -48,6 +48,10 @@ def oauth_authorize(provider): @blueprint.route('/oauth//authorized') def oauth_callback(provider): + import datetime + from pillar.api.utils.authentication import store_token + from pillar.api.utils import utcnow + if current_user.is_authenticated: return redirect(url_for('main.homepage')) @@ -65,7 +69,14 @@ def oauth_callback(provider): user_info = {'id': oauth_user.id, 'email': oauth_user.email, 'full_name': ''} db_user = find_user_in_db(user_info, provider=provider) db_id, status = upsert_user(db_user) - token = generate_and_store_token(db_id) + + if oauth_user.access_token: + # TODO(Sybren): make nr of days configurable, or get from OAuthSignIn subclass. + token_expiry = utcnow() + datetime.timedelta(days=15) + token = store_token(db_id, oauth_user.access_token, token_expiry, + oauth_scopes=oauth_user.scopes) + else: + token = generate_and_store_token(db_id) # Login user pillar.auth.login_user(token['token'], load_from_db=True) diff --git a/tests/test_api/test_auth.py b/tests/test_api/test_auth.py index 79d1cccc..49cad737 100644 --- a/tests/test_api/test_auth.py +++ b/tests/test_api/test_auth.py @@ -69,7 +69,7 @@ class AuthenticationTests(AbstractPillarTest): @responses.activate def test_validate_token__force_not_logged_in(self): from pillar.api.utils import authentication as auth - from pillar.auth import UserClass, current_user + from pillar.auth import UserClass with self.app.test_request_context(): from flask import g @@ -160,8 +160,7 @@ class AuthenticationTests(AbstractPillarTest): def test_save_own_user(self): """Tests that a user can't change their own fields.""" - from pillar.api.utils import authentication as auth - from pillar.api.utils import PillarJSONEncoder, remove_private_keys + from pillar.api.utils import remove_private_keys user_id = self.create_user(roles=['subscriber'], token='token') diff --git a/tests/test_api/test_oauth.py b/tests/test_api/test_oauth.py index 06be3c3c..82b3b112 100644 --- a/tests/test_api/test_oauth.py +++ b/tests/test_api/test_oauth.py @@ -62,6 +62,8 @@ class OAuthTests(AbstractPillarTest): cb = oauth_provider.callback() self.assertEqual(cb.id, '7') self.assertEqual(cb.email, 'harry@blender.org') + self.assertEqual(cb.access_token, 'successful-token') + self.assertEqual(cb.scopes, ['email', 'badge']) @responses.activate def test_provider_callback_missing_code(self):