Reworked subscription/demo role management from web to API level.

In the old situation, users had to be able to change their own roles. This
is inherently insecure.
This commit is contained in:
Sybren A. Stüvel 2017-05-04 17:49:18 +02:00
parent d0557445cd
commit 1a54b723aa
10 changed files with 307 additions and 155 deletions

View File

@ -24,7 +24,8 @@ def blender_cloud_addon_version():
def setup_app(app, url_prefix):
from . import texture_libs, home_project
from . import texture_libs, home_project, subscription
texture_libs.setup_app(app, url_prefix=url_prefix)
home_project.setup_app(app, url_prefix=url_prefix)
subscription.setup_app(app, url_prefix=url_prefix)

View File

@ -0,0 +1,118 @@
import logging
import typing
from flask import current_app, Blueprint
from pillar.api.utils import authorization
log = logging.getLogger(__name__)
blueprint = Blueprint('blender_cloud.subscription', __name__)
def fetch_subscription_info(email: str) -> typing.Optional[dict]:
"""Returns the user info dict from the external subscriptions management server.
:returns: the store user info, or None if the user can't be found or there
was an error communicating. A dict like this is returned:
{
"shop_id": 700,
"cloud_access": 1,
"paid_balance": 314.75,
"balance_currency": "EUR",
"start_date": "2014-08-25 17:05:46",
"expiration_date": "2016-08-24 13:38:45",
"subscription_status": "wc-active",
"expiration_date_approximate": true
}
"""
import requests
from requests.adapters import HTTPAdapter
external_subscriptions_server = current_app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER']
if log.isEnabledFor(logging.DEBUG):
import urllib.parse
log_email = urllib.parse.quote(email)
log.debug('Connecting to store at %s?blenderid=%s',
external_subscriptions_server, log_email)
# Retry a few times when contacting the store.
s = requests.Session()
s.mount(external_subscriptions_server, HTTPAdapter(max_retries=5))
r = s.get(external_subscriptions_server, params={'blenderid': email},
verify=current_app.config['TLS_CERT_FILE'])
if r.status_code != 200:
log.warning("Error communicating with %s, code=%i, unable to check "
"subscription status of user %s",
external_subscriptions_server, r.status_code, email)
return None
store_user = r.json()
if log.isEnabledFor(logging.DEBUG):
import json
log.debug('Received JSON from store API: %s',
json.dumps(store_user, sort_keys=False, indent=4))
return store_user
@blueprint.route('/update-subscription')
@authorization.require_login()
def update_subscription():
"""Updates the subscription status of the current user.
Returns an empty HTTP response.
"""
import pprint
from pillar.api import blender_id, service
from pillar.api.utils import authentication
my_log: logging.Logger = log.getChild('update_subscription')
user_id = authentication.current_user_id()
bid_user = blender_id.fetch_blenderid_user()
if not bid_user:
my_log.warning('Logged in user %s has no BlenderID account! '
'Unable to update subscription status.', user_id)
return
# Use the Blender ID email address to check with the store. At least that reduces the
# number of email addresses that could be out of sync to two (rather than three when we
# use the email address from our local database).
try:
email = bid_user['email']
except KeyError:
my_log.error('Blender ID response did not include an email address, '
'unable to update subscription status: %s',
pprint.pformat(bid_user, compact=True))
return
store_user = fetch_subscription_info(email) or {}
# Handle the role changes via the badger service functionality.
grant_subscriber = store_user.get('cloud_access', 0) == 1
grant_demo = bid_user.get('roles', {}).get('cloud_demo', False)
is_subscriber = authorization.user_has_role('subscriber')
is_demo = authorization.user_has_role('demo')
if grant_subscriber != is_subscriber:
action = 'grant' if grant_subscriber else 'revoke'
log.info('%sing subscriber role to user %s', action, user_id)
service.do_badger(action, 'subscriber', user_id=user_id)
if grant_demo != is_demo:
action = 'grant' if grant_demo else 'revoke'
log.info('%sing demo role to user %s', action, user_id)
service.do_badger(action, 'demo', user_id=user_id)
return '', 204
def setup_app(app, url_prefix):
log.info('Registering blueprint at %s', url_prefix)
app.register_api_blueprint(blueprint, url_prefix=url_prefix)

View File

@ -88,7 +88,8 @@ def upsert_user(db_user, blender_id_user_id):
if 'subscriber' in db_user.get('groups', []):
log.error('Non-ObjectID string found in user.groups: %s', db_user)
raise wz_exceptions.InternalServerError('Non-ObjectID string found in user.groups: %s' % db_user)
raise wz_exceptions.InternalServerError(
'Non-ObjectID string found in user.groups: %s' % db_user)
r = {}
for retry in range(5):
@ -237,5 +238,50 @@ def find_user_in_db(blender_id_user_id, user_info):
return db_user
def fetch_blenderid_user() -> dict:
"""Returns the user info of the currently logged in user from BlenderID.
Returns an empty dict if communication fails.
Example dict:
{
"email": "some@email.example.com",
"full_name": "dr. Sybren A. St\u00fcvel",
"id": 5555,
"roles": {
"admin": true,
"bfct_trainer": false,
"cloud_single_member": true,
"conference_speaker": true,
"network_member": true
}
}
"""
import urllib.parse
import httplib2 # used by the oauth2 package
bid_url = urllib.parse.urljoin(blender_id_endpoint(), 'api/user')
log.debug('Fetching user info from %s', bid_url)
try:
bid_resp = current_app.oauth_blender_id.get(bid_url)
except httplib2.HttpLib2Error:
log.exception('Error getting %s from BlenderID', bid_url)
return {}
if bid_resp.status != 200:
log.warning('Error %i from BlenderID %s: %s', bid_resp.status, bid_url, bid_resp.data)
return {}
if not bid_resp.data:
log.warning('Empty data returned from BlenderID %s', bid_url)
return {}
log.debug('BlenderID returned %s', bid_resp.data)
return bid_resp.data
def setup_app(app, url_prefix):
app.register_api_blueprint(blender_id, url_prefix=url_prefix)

View File

@ -3,11 +3,14 @@
import logging
import blinker
import bson
from flask import Blueprint, current_app, request
from werkzeug import exceptions as wz_exceptions
from pillar.api import local_auth
from pillar.api.utils import mongo
from pillar.api.utils import authorization, authentication, str2id, jsonify
from werkzeug import exceptions as wz_exceptions
blueprint = Blueprint('service', __name__)
log = logging.getLogger(__name__)
@ -70,16 +73,19 @@ def badger():
action, user_email, role, action, role)
return 'Role not allowed', 403
return do_badger(action, user_email, role)
return do_badger(action, role, user_email=user_email)
def do_badger(action, user_email, role):
"""Performs a badger action, returning a HTTP response."""
def do_badger(action: str, role: str, *, user_email: str='', user_id: bson.ObjectId=None):
"""Performs a badger action, returning a HTTP response.
Either user_email or user_id must be given.
"""
if action not in {'grant', 'revoke'}:
raise wz_exceptions.BadRequest('Action %r not supported' % action)
if not user_email:
if not user_email and user_id is None:
raise wz_exceptions.BadRequest('User email not given')
if not role:
@ -88,9 +94,14 @@ def do_badger(action, user_email, role):
users_coll = current_app.data.driver.db['users']
# Fetch the user
db_user = users_coll.find_one({'email': user_email}, projection={'roles': 1, 'groups': 1})
if user_email:
query = {'email': user_email}
else:
query = user_id
db_user = users_coll.find_one(query, projection={'roles': 1, 'groups': 1})
if db_user is None:
log.warning('badger(%s, %s, %s): user not found', action, user_email, role)
log.warning('badger(%s, %s, user_email=%s, user_id=%s): user not found',
action, role, user_email, user_id)
return 'User not found', 404
# Apply the action

View File

@ -93,7 +93,10 @@ def get_blender_id_oauth_token():
def config_oauth_login(app):
config = app.config
if not config.get('SOCIAL_BLENDER_ID'):
log.info('OAuth Blender-ID login not setup.')
log.info('OAuth Blender-ID login not set up, no app config SOCIAL_BLENDER_ID.')
return None
if not config.get('BLENDER_ID_OAUTH_URL'):
log.error('Unable to use Blender ID, missing configuration BLENDER_ID_OAUTH_URL.')
return None
oauth = flask_oauthlib.client.OAuth(app)

View File

@ -1,51 +0,0 @@
"""Cloud subscription info.
Connects to the external subscription server to obtain user info.
"""
import logging
from flask import current_app
import requests
from requests.adapters import HTTPAdapter
log = logging.getLogger(__name__)
def fetch_user(email):
"""Returns the user info dict from the external subscriptions management server.
:returns: the store user info, or None if the user can't be found or there
was an error communicating. A dict like this is returned:
{
"shop_id": 700,
"cloud_access": 1,
"paid_balance": 314.75,
"balance_currency": "EUR",
"start_date": "2014-08-25 17:05:46",
"expiration_date": "2016-08-24 13:38:45",
"subscription_status": "wc-active",
"expiration_date_approximate": true
}
:rtype: dict
"""
external_subscriptions_server = current_app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER']
log.debug('Connecting to store at %s?blenderid=%s', external_subscriptions_server, email)
# Retry a few times when contacting the store.
s = requests.Session()
s.mount(external_subscriptions_server, HTTPAdapter(max_retries=5))
r = s.get(external_subscriptions_server, params={'blenderid': email},
verify=current_app.config['TLS_CERT_FILE'])
if r.status_code != 200:
log.warning("Error communicating with %s, code=%i, unable to check "
"subscription status of user %s",
external_subscriptions_server, r.status_code, email)
return None
store_user = r.json()
return store_user

View File

@ -306,7 +306,7 @@ def badger(action, user_email, role):
with current_app.app_context():
service.fetch_role_to_group_id_map()
response, status = service.do_badger(action, user_email, role)
response, status = service.do_badger(action, role, user_email=user_email)
if status == 204:
log.info('Done.')

View File

@ -12,3 +12,5 @@ ROLES_FOR_UNLIMITED_UPLOADS = {'subscriber', 'demo', 'admin'}
GCLOUD_APP_CREDENTIALS = 'invalid-file-because-gcloud-storage-should-be-mocked-in-tests'
STORAGE_BACKEND = 'local'
EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER = "http://store.localhost/api"

View File

@ -1,8 +1,6 @@
import json
import logging
import httplib2 # used by the oauth2 package
import requests
import urllib.parse
from flask import (abort, Blueprint, current_app, flash, redirect,
render_template, request, session, url_for)
@ -10,8 +8,8 @@ from flask_login import login_required, logout_user, current_user
from flask_oauthlib.client import OAuthException
from werkzeug import exceptions as wz_exceptions
import pillar.api.blender_cloud.subscription
import pillar.auth
from pillar.auth import subscriptions
from pillar.web import system_util
from .forms import UserProfileForm
from .forms import UserSettingsEmailsForm
@ -46,6 +44,8 @@ def login():
@blueprint.route('/oauth/blender-id/authorized')
def blender_id_authorized():
from pillar.api.blender_cloud import subscription
check_oauth_provider(current_app.oauth_blender_id)
try:
oauth_resp = current_app.oauth_blender_id.authorized_response()
@ -70,7 +70,7 @@ def blender_id_authorized():
if current_user is not None:
# Check with the store for user roles. If the user has an active
# subscription, we apply the 'subscriber' role
user_roles_update(current_user.objectid)
subscription.update_subscription()
next_after_login = session.get('next_after_login')
if next_after_login:
@ -188,7 +188,7 @@ def settings_billing():
group = Group.find(group_id, api=api)
groups.append(group.name)
store_user = subscriptions.fetch_user(user.email)
store_user = pillar.api.blender_cloud.subscription.fetch_subscription_info(user.email)
return render_template(
'users/settings/billing.html',
@ -247,91 +247,3 @@ def users_index():
if not current_user.has_role('admin'):
return abort(403)
return render_template('users/index.html')
def user_roles_update(user_id):
"""Update the user's roles based on the store subscription status and BlenderID roles."""
api = system_util.pillar_api()
group_subscriber = Group.find_one({'where': {'name': 'subscriber'}}, api=api)
group_demo = Group.find_one({'where': {'name': 'demo'}}, api=api)
# Fetch the user once outside the loop, because we only need to get the
# subscription status once.
user = User.me(api=api)
# Fetch user info from different sources.
store_user = subscriptions.fetch_user(user.email) or {}
bid_user = fetch_blenderid_user()
grant_subscriber = store_user.get('cloud_access', 0) == 1
grant_demo = bid_user.get('roles', {}).get('cloud_demo', False)
max_retry = 5
for retry_count in range(max_retry):
# Update the user's role & groups for their subscription status.
roles = set(user.roles or [])
groups = set(user.groups or [])
if grant_subscriber:
roles.add('subscriber')
groups.add(group_subscriber._id)
elif 'admin' not in roles:
# Don't take away roles from admins.
roles.discard('subscriber')
groups.discard(group_subscriber._id)
if grant_demo:
roles.add('demo')
groups.add(group_demo._id)
# Only send an API request when the user has actually changed
if set(user.roles or []) == roles and set(user.groups or []) == groups:
break
user.roles = list(roles)
user.groups = list(groups)
try:
user.update(api=api)
except sdk_exceptions.PreconditionFailed:
log.warning('User etag changed while updating roles, retrying.')
else:
# Successful update, so we can stop the loop.
break
# Fetch the user for the next iteration.
if retry_count < max_retry - 1:
user = User.me(api=api)
else:
log.warning('Tried %i times to update user %s, and failed each time. Giving up.',
max_retry, user_id)
def fetch_blenderid_user():
"""Returns the user info from BlenderID.
Returns an empty dict if communication fails.
:rtype: dict
"""
bid_url = urllib.parse.urljoin(current_app.config['BLENDER_ID_ENDPOINT'], 'api/user')
log.debug('Fetching user info from %s', bid_url)
try:
bid_resp = current_app.oauth_blender_id.get(bid_url)
except httplib2.HttpLib2Error:
log.exception('Error getting %s from BlenderID', bid_url)
return {}
if bid_resp.status != 200:
log.warning('Error %i from BlenderID %s: %s', bid_resp.status, bid_url, bid_resp.data)
return {}
if not bid_resp.data:
log.warning('Empty data returned from BlenderID %s', bid_url)
return {}
log.debug('BlenderID returned %s', bid_resp.data)
return bid_resp.data

View File

@ -0,0 +1,110 @@
from unittest import mock
import responses
from pillar.tests import AbstractPillarTest, TEST_EMAIL_ADDRESS
# The OAuth lib doesn't use requests, so we can't use responses to mock it.
# Instead, we use unittest.mock for that.
class RoleUpdatingTest(AbstractPillarTest):
def setUp(self):
super().setUp()
with self.app.test_request_context():
self.create_standard_groups()
def _setup_testcase(self, mocked_fetch_blenderid_user, *,
store_says_cloud_access: bool,
bid_says_cloud_demo: bool):
import urllib.parse
url = '%s?blenderid=%s' % (self.app.config['EXTERNAL_SUBSCRIPTIONS_MANAGEMENT_SERVER'],
urllib.parse.quote(TEST_EMAIL_ADDRESS))
responses.add('GET', url,
json={'shop_id': 58432,
'cloud_access': 1 if store_says_cloud_access else 0,
'paid_balance': 0,
'balance_currency': 'EUR',
'start_date': '2017-05-04 12:07:49',
'expiration_date': '2017-08-04 10:07:49',
'subscription_status': 'wc-active'
},
status=200,
match_querystring=True)
self.mock_blenderid_validate_happy()
mocked_fetch_blenderid_user.return_value = {
'email': TEST_EMAIL_ADDRESS,
'full_name': 'dr. Sybren A. St\u00fcvel',
'id': 5555,
'roles': {
'admin': True,
'bfct_trainer': False,
'conference_speaker': True,
'network_member': True
}
}
if bid_says_cloud_demo:
mocked_fetch_blenderid_user.return_value['roles']['cloud_demo'] = True
@responses.activate
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
def test_store_api_role_grant_subscriber(self, mocked_fetch_blenderid_user):
self._setup_testcase(mocked_fetch_blenderid_user,
store_says_cloud_access=True,
bid_says_cloud_demo=False)
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
expected_status=204)
user_info = self.get('/api/users/me', auth_token='my-happy-token').json()
self.assertEqual(['subscriber'], user_info['roles'])
@responses.activate
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
def test_store_api_role_revoke_subscriber(self, mocked_fetch_blenderid_user):
self._setup_testcase(mocked_fetch_blenderid_user,
store_says_cloud_access=False,
bid_says_cloud_demo=False)
# Make sure this user is currently known as a subcriber.
self.create_user(roles={'subscriber'}, token='my-happy-token')
user_info = self.get('/api/users/me', auth_token='my-happy-token').json()
self.assertEqual(['subscriber'], user_info['roles'])
# And after updating, it shouldn't be.
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
expected_status=204)
user_info = self.get('/api/users/me', auth_token='my-happy-token').json()
self.assertEqual([], user_info['roles'])
@responses.activate
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
def test_bid_api_grant_demo(self, mocked_fetch_blenderid_user):
self._setup_testcase(mocked_fetch_blenderid_user,
store_says_cloud_access=False,
bid_says_cloud_demo=True)
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
expected_status=204)
user_info = self.get('/api/users/me', auth_token='my-happy-token').json()
self.assertEqual(['demo'], user_info['roles'])
@responses.activate
@mock.patch('pillar.api.blender_id.fetch_blenderid_user')
def test_bid_api_role_revoke_subscriber(self, mocked_fetch_blenderid_user):
self._setup_testcase(mocked_fetch_blenderid_user,
store_says_cloud_access=False,
bid_says_cloud_demo=False)
# Make sure this user is currently known as demo user.
self.create_user(roles={'demo'}, token='my-happy-token')
user_info = self.get('/api/users/me', auth_token='my-happy-token').json()
self.assertEqual(['demo'], user_info['roles'])
# And after updating, it shouldn't be.
self.get('/api/bcloud/update-subscription', auth_token='my-happy-token',
expected_status=204)
user_info = self.get('/api/users/me', auth_token='my-happy-token').json()
self.assertEqual([], user_info['roles'])