Switch to side-by-side view

--- a
+++ b/qiita_db/handlers/oauth2.py
@@ -0,0 +1,406 @@
+# -----------------------------------------------------------------------------
+# Copyright (c) 2014--, The Qiita Development Team.
+#
+# Distributed under the terms of the BSD 3-clause License.
+#
+# The full license is in the file LICENSE, distributed with this software.
+# -----------------------------------------------------------------------------
+
+from base64 import urlsafe_b64decode
+from string import ascii_letters, digits
+import datetime
+from random import SystemRandom
+import functools
+from traceback import format_exception
+
+from tornado.web import RequestHandler
+from qiita_core.qiita_settings import r_client
+
+from qiita_core.exceptions import (IncorrectPasswordError, IncorrectEmailError,
+                                   UnverifiedEmailError)
+import qiita_db as qdb
+
+
+def _oauth_error(handler, error_msg, error):
+    """Set expected status and error formatting for Oauth2 style error
+
+   Parameters
+   ----------
+   error_msg : str
+        Human parsable error message
+    error : str
+        Oauth2 controlled vocab error
+
+    Returns
+    -------
+    Writes out Oauth2 formatted error JSON of
+        {error: error,
+         error_description: error_msg}
+
+    Notes
+    -----
+    Expects handler to be a tornado RequestHandler or subclass
+    """
+    handler.set_status(400)
+    handler.write({'error': error,
+                   'error_description': error_msg})
+    handler.finish()
+
+
+def authenticate_oauth(f):
+    """Decorate methods to require valid Oauth2 Authorization header[1]
+
+    If a valid header is given, the handoff is done and the page is rendered.
+    If an invalid header is given, a 400 error code is returned and the json
+    error message is automatically sent.
+
+    Returns
+    -------
+    Sends oauth2 formatted error JSON if authorizaton fails
+
+    Notes
+    -----
+    Expects handler to be a tornado RequestHandler or subclass
+
+    References
+    ----------
+    [1] The OAuth 2.0 Authorization Framework.
+    http://tools.ietf.org/html/rfc6749
+    """
+    @functools.wraps(f)
+    def wrapper(handler, *args, **kwargs):
+        header = handler.request.headers.get('Authorization', None)
+        if header is None:
+            _oauth_error(handler, 'Oauth2 error: invalid access token',
+                         'invalid_request')
+            return
+        token_info = header.split()
+        # Based on RFC6750 if reply is not 2 elements in the format of:
+        # ['Bearer', token] we assume a wrong reply
+        if len(token_info) != 2 or token_info[0] != 'Bearer':
+            _oauth_error(handler, 'Oauth2 error: invalid access token',
+                         'invalid_grant')
+            return
+
+        token = token_info[1]
+        db_token = r_client.hgetall(token)
+        if not db_token:
+            # token has timed out or never existed
+            _oauth_error(handler, 'Oauth2 error: token has timed out',
+                         'invalid_grant')
+            return
+        # Check daily rate limit for key if password style key
+        if db_token[b'grant_type'] == b'password':
+            limit_key = '%s_%s_daily_limit' % (
+                db_token[b'client_id'].decode('ascii'),
+                db_token[b'user'].decode('ascii'))
+            limiter = r_client.get(limit_key)
+            if limiter is None:
+                # Set limit to 5,000 requests per day
+                r_client.setex(limit_key, 86400, 5000)
+            else:
+                r_client.decr(limit_key)
+                if int(r_client.get(limit_key)) <= 0:
+                    _oauth_error(
+                        handler, 'Oauth2 error: daily request limit reached',
+                        'invalid_grant')
+                    return
+
+        return f(handler, *args, **kwargs)
+    return wrapper
+
+
+class OauthBaseHandler(RequestHandler):
+    def write_error(self, status_code, **kwargs):
+        """Overriding the default write error in tornado RequestHandler
+
+        Instead of writing all errors to stderr, this writes them to the logger
+        tables.
+
+        Parameters
+        ----------
+        status_code : int
+            HTML status code of the error
+        **kwargs : dict
+            Other parameters describing the error
+
+        Notes
+        -----
+        This function is automatically called by the tornado package on errors,
+        and should never be called directly.
+        """
+        exc_info = kwargs['exc_info']
+
+        # We don't need to log 403, 404 or 405 failures in the logging table
+        if status_code not in {403, 404, 405}:
+            # log the error
+            error_lines = ['%s\n' % line
+                           for line in format_exception(*exc_info)]
+            trace_info = ''.join(error_lines)
+            req_dict = self.request.__dict__
+            # must trim body to 1024 chars to prevent huge error messages
+            req_dict['body'] = req_dict.get('body', '')[:1024]
+            request_info = ''.join(['<strong>%s</strong>: %s\n' %
+                                   (k, req_dict[k]) for k in
+                                    req_dict.keys() if k != 'files'])
+            error = exc_info[1]
+            qdb.logger.LogEntry.create(
+                'Runtime',
+                'ERROR:\n%s\nTRACE:\n%s\nHTTP INFO:\n%s\n' %
+                (error, trace_info, request_info))
+
+        message = str(exc_info[1])
+        if hasattr(exc_info[1], 'log_message'):
+            message = exc_info[1].log_message
+
+        self.finish(message)
+
+    def head(self):
+        """Adds proper response for head requests"""
+        self.finish()
+
+
+class TokenAuthHandler(OauthBaseHandler):
+    def generate_access_token(self, length=55):
+        """Creates the random alphanumeric token
+
+        Parameters
+        ----------
+        length : int, optional
+            Length of token to generate. Default 55 characters.
+            Can be a max of 255 characters, which is a hard HTTP transfer limit
+
+        Returns
+        -------
+        str
+            Random alphanumeric string of passed length
+
+        Raises
+        ------
+        ValueError
+            length is not between 1 and 255, inclusive
+        """
+        if not 0 < length < 256:
+            raise ValueError("Invalid token length: %d" % length)
+
+        pool = ascii_letters + digits
+        return ''.join((SystemRandom().choice(pool) for _ in range(length)))
+
+    def set_token(self, client_id, grant_type, user=None, timeout=3600):
+        """Create access token for the client on redis and send json response
+
+        Parameters
+        ----------
+        client_id : str
+            Client that requested the token
+        grant_type : str
+            Type of key being requested
+        user : str, optional
+            If password grant type requested, the user requesting the key.
+        timeout : int, optional
+            The timeout, in seconds, for the token. Default 3600
+
+        Returns
+        -------
+        Writes token information JSON in the form expected by RFC6750:
+        {'access_token': token,
+         'token_type': 'Bearer',
+         'expires_in': timeout}
+
+         access_token: the actual token to use
+         token_type: 'Bearer', which is the expected token type for Oauth2
+         expires_in: time to token expiration, in seconds.
+        """
+        token = self.generate_access_token()
+
+        token_info = {
+            'timestamp': datetime.datetime.now().strftime('%m-%d-%y %H:%M:%S'),
+            'client_id': client_id,
+            'grant_type': grant_type
+        }
+        if user:
+            token_info['user'] = user
+
+        r_client.hmset(token, token_info)
+        r_client.expire(token, timeout)
+        if grant_type == 'password':
+            # Check if client has access limit key, and if not, create it
+            limit_key = '%s_%s_daily_limit' % (client_id, user)
+            limiter = r_client.get(limit_key)
+            if limiter is None:
+                # Set limit to 5,000 requests per day
+                r_client.setex(limit_key, 86400, 5000)
+
+        self.write({'access_token': token,
+                    'token_type': 'Bearer',
+                    'expires_in': timeout})
+        self.finish()
+
+    def validate_client(self, client_id, client_secret):
+        """Make sure client exists, then set the token and send it
+
+        Parameters
+        ----------
+        client_id : str
+            The client making the request
+        client_secret : str
+            The secret key for the client
+
+        Returns
+        -------
+        Writes out Oauth2 formatted error JSON if error occured
+            {error: error,
+             error_description: error_msg}
+
+        error: RFC6750 controlled vocabulary of errors
+         error_description: Human readable explanation of error
+        """
+        with qdb.sql_connection.TRN:
+            sql = """SELECT EXISTS(
+                        SELECT *
+                        FROM qiita.oauth_identifiers
+                        WHERE client_id = %s AND client_secret = %s)"""
+            qdb.sql_connection.TRN.add(sql, [client_id, client_secret])
+            if qdb.sql_connection.TRN.execute_fetchlast():
+                self.set_token(client_id, 'client')
+            else:
+                _oauth_error(self, 'Oauth2 error: invalid client information',
+                             'invalid_client')
+
+    def validate_resource_owner(self, username, password, client_id):
+        """Make sure user and client exist, then set the token and send it
+
+        Parameters
+        ----------
+        username : str
+            The username to validate
+        password : str
+            The password for the username
+        client_id : str
+            The client making the request
+
+        Returns
+        -------
+        Writes out Oauth2 formatted error JSON if error occured
+            {error: error,
+             error_description: error_msg}
+
+        error: RFC6750 controlled vocabulary of errors
+         error_description: Human readable explanation of error
+        """
+        try:
+            qdb.user.User.login(username, password)
+        except (IncorrectEmailError, IncorrectPasswordError,
+                UnverifiedEmailError):
+            _oauth_error(self, 'Oauth2 error: invalid user information',
+                         'invalid_client')
+            return
+
+        with qdb.sql_connection.TRN:
+            sql = """SELECT EXISTS(
+                        SELECT *
+                        FROM qiita.oauth_identifiers
+                        WHERE client_id = %s AND client_secret IS NULL)"""
+            qdb.sql_connection.TRN.add(sql, [client_id])
+            if qdb.sql_connection.TRN.execute_fetchlast():
+                self.set_token(client_id, 'password', user=username)
+            else:
+                _oauth_error(self, 'Oauth2 error: invalid client information',
+                             'invalid_client')
+
+    def post(self):
+        """ Authenticate given information as per RFC6750
+
+        Parameters
+        ----------
+        grant_type : {'client', 'password'}
+            What type of token to grant
+        client_id : str
+            Client requesting the token
+
+        One of the following, if password grant type:
+        HTTP Header in the form
+            Authorization: Bearer [base64encode of username:password]
+
+        OR
+
+        username : str
+            Username to authenticate
+        password : str
+            Password for the username
+
+        If client grant type:
+        client_secret : str
+            client authentication secret
+
+        Returns
+        -------
+        Writes token information JSON in the form expected by RFC6750:
+        {'access_token': token,
+         'token_type': 'Bearer',
+         'expires_in': timeout}
+
+         access_token: the actual token to use
+         token_type: 'Bearer', which is the expected token type for Oauth2
+         expires_in: time to token expiration, in seconds.
+
+         or an error message in the form
+         {error: error,
+         error_description: error_msg}
+
+         error: RFC6750 controlled vocabulary of errors
+         error_description: Human readable explanation of error
+         """
+        # first check for header version of sending auth, meaning client ID
+        header = self.request.headers.get('Authorization', None)
+        if header is not None:
+            header_info = header.split()
+            # Based on RFC6750 if reply is not 2 elements in the format of:
+            # ['Basic', base64 encoded username:password] we assume the header
+            # is invalid
+            if len(header_info) != 2 or header_info[0] != 'Basic':
+                # Invalid Authorization header type for this page
+                _oauth_error(self, 'Oauth2 error: invalid token type',
+                             'invalid_request')
+                return
+
+            # Get client information from the header and validate it
+            grant_type = self.get_argument('grant_type', None)
+            if grant_type != 'client':
+                _oauth_error(self, 'Oauth2 error: invalid grant_type',
+                             'invalid_request')
+                return
+            try:
+                client_id, client_secret = urlsafe_b64decode(
+                    header_info[1]).decode('ascii').split(':')
+            except ValueError:
+                # Split didn't work, so invalid information sent
+                _oauth_error(self, 'Oauth2 error: invalid base64 encoded info',
+                             'invalid_request')
+                return
+            self.validate_client(client_id, client_secret)
+            return
+
+        # Otherwise, do either password or client based authentication
+        client_id = self.get_argument('client_id', None)
+        grant_type = self.get_argument('grant_type', None)
+        if grant_type == 'password':
+            username = self.get_argument('username', None)
+            password = self.get_argument('password', None)
+            if not all([username, password, client_id]):
+                _oauth_error(self, 'Oauth2 error: missing user information',
+                             'invalid_request')
+            else:
+                self.validate_resource_owner(username, password, client_id)
+
+        elif grant_type == 'client':
+            client_secret = self.get_argument('client_secret', None)
+            if not all([client_id, client_secret]):
+                _oauth_error(self, 'Oauth2 error: missing client information',
+                             'invalid_request')
+                return
+            self.validate_client(client_id, client_secret)
+        else:
+            _oauth_error(self, 'Oauth2 error: invalid grant_type',
+                         'unsupported_grant_type')
+            return