From 0983474e7648d47485a4e229f43493bd3d431997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Tue, 11 Sep 2018 15:36:25 +0200 Subject: [PATCH] Store Blender ID OAuth scopes in MongoDB + request `badge` scope too This also changes the way we treat Blender ID tokens. Before, the Blender ID token was discarded and a random token was generated & stored. Now the actual Blender ID token is stored. The Facebook and Google OAuth code still uses the old approach of generating a new token. Not sure what the added value is, though, because once the Django session is gone there is nothing left to authenticate the user and thus the random token is useless anyway. --- pillar/api/eve_settings.py | 7 +++++++ pillar/api/utils/authentication.py | 12 ++++++++++-- pillar/auth/oauth.py | 18 +++++++++++++----- pillar/web/users/routes.py | 13 ++++++++++++- tests/test_api/test_auth.py | 5 ++--- tests/test_api/test_oauth.py | 2 ++ 6 files changed, 46 insertions(+), 11 deletions(-) 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):