Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth2 support allowing public and user access #2099

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions qiita_db/handlers/artifact.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from json import loads

import qiita_db as qdb
from .oauth2 import OauthBaseHandler, authenticate_oauth
from .oauth2 import OauthBaseHandler, authenticate_oauth2


def _get_artifact(a_id):
Expand Down Expand Up @@ -46,7 +46,7 @@ def _get_artifact(a_id):


class ArtifactHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def get(self, artifact_id):
"""Retrieves the artifact information

Expand Down Expand Up @@ -109,7 +109,7 @@ def get(self, artifact_id):

self.write(response)

@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def patch(self, artifact_id):
"""Patches the artifact information

Expand Down Expand Up @@ -140,7 +140,7 @@ def patch(self, artifact_id):


class ArtifactAPItestHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self):
"""Creates a new artifact

Expand Down Expand Up @@ -180,7 +180,7 @@ def post(self):


class ArtifactTypeHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self):
"""Creates a new artifact type

Expand Down
4 changes: 2 additions & 2 deletions qiita_db/handlers/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
# The full license is in the file LICENSE, distributed with this software.
# -----------------------------------------------------------------------------

from .oauth2 import OauthBaseHandler, authenticate_oauth
from .oauth2 import OauthBaseHandler, authenticate_oauth2
import qiita_db as qdb


class ResetAPItestHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self):
qdb.environment_manager.drop_and_rebuild_tst_database()
160 changes: 116 additions & 44 deletions qiita_db/handlers/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,66 +47,138 @@ def _oauth_error(handler, error_msg, error):
handler.finish()


def authenticate_oauth(f):
def _check_oauth2_header(handler):
"""Check if the oauth2 header is valid

Parameters
----------
handler : tornado.web.RequestHandler instance
The handler instance being requested

Returns
-------
errtype
The type of error, None if no error was observed
errdesc
A description of the error, None if no error was observed.
client_id
The observed client ID. This field is None if any error was observed.
"""
header = handler.request.headers.get('Authorization', None)

if header is None:
return ('invalid_request', 'Oauth2 error: invalid access token', None)

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':
return ('invalid_grant', 'Oauth2 error: invalid access token', None)

token = token_info[1]
db_token = r_client.hgetall(token)
if not db_token:
# token has timed out or never existed
return ('invalid_grant', 'Oauth2 error: token has timed out', None)

# Check daily rate limit for key if password style key
if db_token['grant_type'] == 'password':
limit_key = '%s_%s_daily_limit' % (db_token['client_id'],
db_token['user'])
limiter = r_client.get(limit_key)
if limiter is None:
# Set limit to 5,000 requests per day
r_client.setex(limit_key, 5000, 86400)
else:
r_client.decr(limit_key)
if int(r_client.get(limit_key)) <= 0:
return ('invalid_grant',
'Oauth2 error: daily request limit reached', None)

return (None, None, db_token['client_id'])


class authenticate_oauth2:
"""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
Attributes
----------
default_public : bool
If True, execute the handler if a) the oauth2 token is acceptable or
b) if the Authorization header is not present. If False, the handler
will only be executed if the oauth2 token is acceptable.
inject_user : bool
If True, monkey patch the handler's get_current_user method to return
the instance of the User associated with the token's client ID. If
False, get_current_user is not monkey patched. If default_public is
also True, the default User returned is "demo@microbio.me"

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
def __init__(self, default_public=False, inject_user=False):
self.default_public = default_public
self.inject_user = inject_user

def get_user_maker(self, cid):
"""Produce a function which acts like get_current_user"""
def f():
if cid is None:
return qdb.user.User("demo@microbio.me")
else:
return qdb.user.User.from_client_id(cid)
return f

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['grant_type'] == 'password':
limit_key = '%s_%s_daily_limit' % (db_token['client_id'],
db_token['user'])
limiter = r_client.get(limit_key)
if limiter is None:
# Set limit to 5,000 requests per day
r_client.setex(limit_key, 5000, 86400)
def __call__(self, f):
"""Handle oauth, and execute the handler's method if appropriate

Parameters
----------
f : function
The function decorated is expected to be a member method of a
subclass of `Tornado.web.RequestHandler`

Notes
-----
If an error with oauth2 occurs, a status code of 400 is set, a message
about the error is sent out over `write` and the response is ended
with `finish`. This happens without control being passed to the
handler, and in this situation, the handler is not executed.
"""
@functools.wraps(f)
def wrapper(handler, *args, **kwargs):
errtype, errdesc, cid = _check_oauth2_header(handler)

if self.default_public:
# no error, or no authorization header. We should error if
# oauth is actually attempted but there was an auth issue
# (e.g., rate limit hit)
if errtype not in (None, 'invalid_request'):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test?

_oauth_error(handler, errdesc, errtype)
return

if self.inject_user:
handler.get_current_user = self.get_user_maker(cid)
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')
if errtype is not None:
_oauth_error(handler, errdesc, errtype)
return
if self.inject_user:
if cid is None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test?

raise ValueError("cid is None, without an oauth "
"error. This should never happen.")
else:
handler.get_current_user = self.get_user_maker(cid)

return f(handler, *args, **kwargs)

return f(handler, *args, **kwargs)
return wrapper
return wrapper


class OauthBaseHandler(RequestHandler):
Expand Down
12 changes: 6 additions & 6 deletions qiita_db/handlers/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from tornado.web import HTTPError

from .oauth2 import OauthBaseHandler, authenticate_oauth
from .oauth2 import OauthBaseHandler, authenticate_oauth2
from qiita_core.qiita_settings import qiita_config
import qiita_db as qdb

Expand Down Expand Up @@ -50,7 +50,7 @@ def _get_plugin(name, version):


class PluginHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def get(self, name, version):
"""Retrieve the plugin information

Expand Down Expand Up @@ -91,7 +91,7 @@ def get(self, name, version):


class CommandListHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self, name, version):
with qdb.sql_connection.TRN:
plugin = _get_plugin(name, version)
Expand Down Expand Up @@ -154,7 +154,7 @@ def _get_command(plugin_name, plugin_version, cmd_name):


class CommandHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def get(self, plugin_name, plugin_version, cmd_name):
"""Retrieve the command information

Expand Down Expand Up @@ -193,7 +193,7 @@ def get(self, plugin_name, plugin_version, cmd_name):


class CommandActivateHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self, plugin_name, plugin_version, cmd_name):
"""Activates the command

Expand All @@ -214,7 +214,7 @@ def post(self, plugin_name, plugin_version, cmd_name):


class ReloadPluginAPItestHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self):
"""Reloads the plugins"""
conf_files = glob(join(qiita_config.plugin_dir, "*.conf"))
Expand Down
8 changes: 4 additions & 4 deletions qiita_db/handlers/prep_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import pandas as pd

import qiita_db as qdb
from .oauth2 import OauthBaseHandler, authenticate_oauth
from .oauth2 import OauthBaseHandler, authenticate_oauth2


def _get_prep_template(pid):
Expand Down Expand Up @@ -48,7 +48,7 @@ def _get_prep_template(pid):


class PrepTemplateDBHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def get(self, prep_id):
"""Retrieves the prep template information

Expand Down Expand Up @@ -89,7 +89,7 @@ def get(self, prep_id):


class PrepTemplateDataHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def get(self, prep_id):
"""Retrieves the prep contents

Expand All @@ -111,7 +111,7 @@ def get(self, prep_id):


class PrepTemplateAPItestHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self):
prep_info_dict = loads(self.get_argument('prep_info'))
study = self.get_argument('study')
Expand Down
12 changes: 6 additions & 6 deletions qiita_db/handlers/processing_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from qiita_core.qiita_settings import qiita_config
import qiita_db as qdb
from .oauth2 import OauthBaseHandler, authenticate_oauth
from .oauth2 import OauthBaseHandler, authenticate_oauth2


def _get_job(job_id):
Expand Down Expand Up @@ -71,7 +71,7 @@ def _job_completer(job_id, payload):


class JobHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def get(self, job_id):
"""Get the job information

Expand Down Expand Up @@ -103,7 +103,7 @@ def get(self, job_id):


class HeartbeatHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self, job_id):
"""Update the heartbeat timestamp of the job

Expand All @@ -124,7 +124,7 @@ def post(self, job_id):


class ActiveStepHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self, job_id):
"""Changes the current exectuion step of the given job

Expand All @@ -146,7 +146,7 @@ def post(self, job_id):


class CompleteHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self, job_id):
"""Updates the job to one of the completed statuses: 'success', 'error'

Expand All @@ -171,7 +171,7 @@ def post(self, job_id):


class ProcessingJobAPItestHandler(OauthBaseHandler):
@authenticate_oauth
@authenticate_oauth2(default_public=False, inject_user=False)
def post(self):
user = self.get_argument('user', 'test@foo.bar')
s_name, s_version, cmd_name = loads(self.get_argument('command'))
Expand Down
Loading