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