From 2d542d0ddd20ec0d5417b40d1bd51922bbdf037a Mon Sep 17 00:00:00 2001 From: Nathaniel Kofalt <nathaniel@kofalt.com> Date: Thu, 17 Mar 2016 12:35:08 -0500 Subject: [PATCH] Avatar improvements --- api/api.py | 2 + api/base.py | 112 ++++++++++++++++++++++++------------ api/handlers/listhandler.py | 2 - api/handlers/userhandler.py | 69 ++++++++++++++++++++-- 4 files changed, 141 insertions(+), 44 deletions(-) diff --git a/api/api.py b/api/api.py index 091febb9..1c4335e9 100644 --- a/api/api.py +++ b/api/api.py @@ -86,8 +86,10 @@ routes = [ webapp2.Route(r'/api/users', userhandler.UserHandler, methods=['POST']), webapp2_extras.routes.PathPrefixRoute(r'/api/users', [ webapp2.Route(r'/self', userhandler.UserHandler, handler_method='self', methods=['GET']), + webapp2.Route(r'/self/avatar', userhandler.UserHandler, handler_method='self_avatar', methods=['GET']), webapp2.Route(_format(r'/<_id:{user_id_re}>'), userhandler.UserHandler, name='user'), webapp2.Route(_format(r'/<uid:{user_id_re}>/groups'), grouphandler.GroupHandler, handler_method='get_all', methods=['GET'], name='groups'), + webapp2.Route(_format(r'/<uid:{user_id_re}>/avatar'), userhandler.UserHandler, handler_method='avatar', methods=['GET'], name='avatar'), ]), webapp2.Route(r'/api/jobs', jobs.Jobs), webapp2_extras.routes.PathPrefixRoute(r'/api/jobs', [ diff --git a/api/base.py b/api/base.py index 8704aaf5..397f92be 100644 --- a/api/base.py +++ b/api/base.py @@ -27,7 +27,6 @@ class RequestHandler(webapp2.RequestHandler): self.initialize(request, response) self.debug = config.get_item('core', 'insecure') request_start = datetime.datetime.utcnow() - provider_avatar = None # set uid, source_site, public_request, and superuser self.uid = None @@ -45,37 +44,7 @@ class RequestHandler(webapp2.RequestHandler): # User (oAuth) authentication if access_token: - cached_token = config.db.authtokens.find_one({'_id': access_token}) - if cached_token: - self.uid = cached_token['uid'] - log.debug('looked up cached token in %dms' % ((datetime.datetime.utcnow() - request_start).total_seconds() * 1000.)) - else: - r = requests.get(config.get_item('auth', 'id_endpoint'), headers={'Authorization': 'Bearer ' + access_token}) - if r.ok: - identity = json.loads(r.content) - self.uid = identity.get('email') - if not self.uid: - self.abort(400, 'OAuth2 token resolution did not return email address') - config.db.authtokens.replace_one({'_id': access_token}, {'uid': self.uid, 'timestamp': request_start}, upsert=True) - config.db.users.update_one({'_id': self.uid, 'firstlogin': None}, {'$set': {'firstlogin': request_start}}) - config.db.users.update_one({'_id': self.uid}, {'$set': {'lastlogin': request_start}}) - log.debug('looked up remote token in %dms' % ((datetime.datetime.utcnow() - request_start).total_seconds() * 1000.)) - - # Set user's auth provider avatar - # TODO: switch on auth.provider rather than manually comparing endpoint URL. - if config.get_item('auth', 'id_endpoint') == 'https://www.googleapis.com/plus/v1/people/me/openIdConnect': - provider_avatar = identity.get('picture', '') - # Remove attached size param from URL. - u = urlparse.urlparse(provider_avatar) - query = urlparse.parse_qs(u.query) - query.pop('sz', None) - u = u._replace(query=urllib.urlencode(query, True)) - provider_avatar = urlparse.urlunparse(u) - else: - err_msg = 'Invalid OAuth2 token.' - headers = {'WWW-Authenticate': 'Bearer realm="{}", error="invalid_token", error_description="{}"'.format(site_id, err_msg)} - log.warn('{} Request headers: {}'.format(err_msg, str(self.request.headers.items()))) - self.abort(401, err_msg, headers=headers) + self.uid = self.authenticate_user(access_token) # 'Debug' (insecure) setting: allow request to act as requested user elif self.debug and self.get_param('user'): @@ -101,8 +70,8 @@ class RequestHandler(webapp2.RequestHandler): self.abort(402, remote_instance + ' is not an authorized remote instance') else: self.abort(401, 'no valid SSL client certificate') - self.user_site = self.source_site or site_id + self.user_site = self.source_site or site_id self.public_request = not drone_request and not self.uid if self.public_request or self.source_site: @@ -113,9 +82,6 @@ class RequestHandler(webapp2.RequestHandler): user = config.db.users.find_one({'_id': self.uid}, ['root']) if not user: self.abort(403, 'user ' + self.uid + ' does not exist') - if provider_avatar: - config.db.users.update_one({'_id': self.uid, 'avatar': None}, {'$set':{'avatar': provider_avatar, 'modified': request_start}}) - config.db.users.update_one({'_id': self.uid, 'avatars.provider': {'$ne': provider_avatar}}, {'$set':{'avatars.provider': provider_avatar, 'modified': request_start}}) if self.is_true('root'): if user.get('root'): self.superuser_request = True @@ -126,6 +92,79 @@ class RequestHandler(webapp2.RequestHandler): self.set_origin(drone_request, drone_name) + def authenticate_user(self, access_token): + """ + AuthN for user accounts. Calls self.abort on failure. + + Returns the user's UID. + """ + + uid = None + timestamp = datetime.datetime.utcnow() + cached_token = config.db.authtokens.find_one({'_id': access_token}) + + if cached_token: + uid = cached_token['uid'] + log.debug('looked up cached token in %dms' % ((datetime.datetime.utcnow() - timestamp).total_seconds() * 1000.)) + else: + uid = self.validate_oauth_token(access_token, timestamp) + log.debug('looked up remote token in %dms' % ((datetime.datetime.utcnow() - timestamp).total_seconds() * 1000.)) + + # Cache the token for future requests + config.db.authtokens.replace_one({'_id': access_token}, {'uid': uid, 'timestamp': timestamp}, upsert=True) + + return uid + + def validate_oauth_token(self, access_token, timestamp): + """ + Validates a token assertion against the configured ID endpoint. Calls self.abort on failure. + + Returns the user's UID. + """ + + r = requests.get(config.get_item('auth', 'id_endpoint'), headers={'Authorization': 'Bearer ' + access_token}) + + if not r.ok: + # Oauth authN failed; for now assume it was an invalid token. Could be more accurate in the future. + err_msg = 'Invalid OAuth2 token.' + site_id = config.get_item('site', 'id') + headers = {'WWW-Authenticate': 'Bearer realm="{}", error="invalid_token", error_description="{}"'.format(site_id, err_msg)} + log.warn('{} Request headers: {}'.format(err_msg, str(self.request.headers.items()))) + self.abort(401, err_msg, headers=headers) + + identity = json.loads(r.content) + uid = identity.get('email') + + if not uid: + self.abort(400, 'OAuth2 token resolution did not return email address') + + # If this is the first time they've logged in, record that + config.db.users.update_one({'_id': self.uid, 'firstlogin': None}, {'$set': {'firstlogin': timestamp}}) + + # Unconditionally set their most recent login time + config.db.users.update_one({'_id': self.uid}, {'$set': {'lastlogin': timestamp}}) + + # Set user's auth provider avatar + # TODO: switch on auth.provider rather than manually comparing endpoint URL. + if config.get_item('auth', 'id_endpoint') == 'https://www.googleapis.com/plus/v1/people/me/openIdConnect': + # A google-specific avatar URL is provided in the identity return. + provider_avatar = identity.get('picture', '') + + # Remove attached size param from URL. + u = urlparse.urlparse(provider_avatar) + query = urlparse.parse_qs(u.query) + query.pop('sz', None) + u = u._replace(query=urllib.urlencode(query, True)) + provider_avatar = urlparse.urlunparse(u) + + # If the user has no avatar set, mark this as their chosen avatar. + config.db.users.update_one({'_id': uid, 'avatar': {'$exists': False}}, {'$set':{'avatar': provider_avatar, 'modified': timestamp}}) + + # Update the user's provider avatar if it has changed. + config.db.users.update_one({'_id': uid, 'avatars.provider': {'$ne': provider_avatar}}, {'$set':{'avatars.provider': provider_avatar, 'modified': timestamp}}) + + return uid + def set_origin(self, drone_request, drone_name): """ Add an origin to the request object. Used later in request handler logic. @@ -177,7 +216,6 @@ class RequestHandler(webapp2.RequestHandler): 'id': None } - # print json.dumps(self.origin) def is_true(self, param): return self.request.GET.get(param, '').lower() in ('1', 'true') diff --git a/api/handlers/listhandler.py b/api/handlers/listhandler.py index fd48f721..bf97645b 100644 --- a/api/handlers/listhandler.py +++ b/api/handlers/listhandler.py @@ -381,8 +381,6 @@ class FileListHandler(ListHandler): # Authorize: confirm project exists project = config.db['projects'].find_one({ '_id': bson.ObjectId(_id)}) - print project - if project is None: raise Exception('Project ' + _id + ' does not exist') diff --git a/api/handlers/userhandler.py b/api/handlers/userhandler.py index 7bc247ad..98ea7a62 100644 --- a/api/handlers/userhandler.py +++ b/api/handlers/userhandler.py @@ -1,5 +1,6 @@ -import hashlib import datetime +import hashlib +import pymongo import requests from .. import base @@ -89,11 +90,7 @@ class UserHandler(base.RequestHandler): payload['created'] = payload['modified'] = datetime.datetime.utcnow() payload['root'] = payload.get('root', False) payload.setdefault('email', payload['_id']) - gravatar = 'https://gravatar.com/avatar/' + hashlib.md5(payload['email']).hexdigest() + '?s=512' - if requests.head(gravatar, params={'d': '404'}): - payload.setdefault('avatar', gravatar) payload.setdefault('avatars', {}) - payload['avatars'].setdefault('gravatar', gravatar) result = mongo_validator(permchecker(self.storage.exec_op))('POST', payload=payload) if result.acknowledged: return {'_id': result.inserted_id} @@ -103,6 +100,68 @@ class UserHandler(base.RequestHandler): def _init_storage(self): self.storage = containerstorage.ContainerStorage('users', use_object_id=False) + def avatar(self, uid): + self._init_storage() + self.resolve_avatar(uid, default=self.request.GET.get('default')) + + def self_avatar(self): + if self.uid is None: + self.abort(404, 'not a logged-in user') + self._init_storage() + self.resolve_avatar(self.uid, default=self.request.GET.get('default')) + + def resolve_avatar(self, email, default=None): + """ + Given an email, redirects to their avatar. + On failure, either 404s or redirects to default, if provided. + """ + + # Storage throws a 404; we want to catch that and handle it separately in the case of a provided default. + try: + user = self._get_user(email) + except: + user = {} + + avatar = user.get('avatar', None) + avatars = user.get('avatars', {}) + + # If the user exists but has no set avatar, try to get one + if user and avatar is None: + gravatar = self._resolve_gravatar(email) + + if gravatar is not None: + user = config.db['users'].find_one_and_update({ + '_id': email, + }, { + '$set': { + 'avatar': gravatar, + 'avatars.gravatar': gravatar, + } + }, + return_document=pymongo.collection.ReturnDocument.AFTER + ) + + if user.get('avatar', None): + # Our data is unicode, but webapp2 wants a python-string for its headers. + self.redirect(str(user['avatar']), code=307) + elif default is not None: + self.redirect(str(default), code=307) + else: + self.abort(404, 'no avatar') + + def _resolve_gravatar(self, email): + """ + Given an email, returns a URL if that email has a gravatar set. + Otherwise returns None. + """ + + gravatar = 'https://gravatar.com/avatar/' + hashlib.md5(email).hexdigest() + '?s=512' + + if requests.head(gravatar, params={'d': '404'}): + return gravatar + else: + return None + def _get_user(self, _id): user = self.storage.get_container(_id) if user is not None: -- GitLab