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:
@@ -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)
|
||||
|
118
pillar/api/blender_cloud/subscription.py
Normal file
118
pillar/api/blender_cloud/subscription.py
Normal 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)
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user