lbry-sdk/lbrynet/daemon/auth/server.py

533 lines
20 KiB
Python
Raw Normal View History

import logging
import urlparse
2017-03-23 20:37:28 +01:00
import json
2017-05-28 22:02:22 +02:00
import inspect
from decimal import Decimal
2016-09-22 03:36:06 +02:00
from zope.interface import implements
from twisted.web import server, resource
from twisted.internet import defer
2016-11-11 19:40:19 +01:00
from twisted.python.failure import Failure
2017-01-09 20:03:29 +01:00
from twisted.internet.error import ConnectionDone, ConnectionLost
from txjsonrpc import jsonrpclib
2017-03-23 01:27:01 +01:00
from traceback import format_exc
2017-01-09 20:03:29 +01:00
from lbrynet import conf
2017-02-15 18:35:49 +01:00
from lbrynet.core.Error import InvalidAuthenticationToken
2017-01-09 20:03:29 +01:00
from lbrynet.core import utils
from lbrynet.daemon.auth.util import APIKey, get_auth_message
from lbrynet.daemon.auth.client import LBRY_SECRET
2017-05-28 22:02:22 +02:00
from lbrynet.undecorated import undecorated
log = logging.getLogger(__name__)
2017-01-09 20:03:25 +01:00
EMPTY_PARAMS = [{}]
2017-03-23 01:27:01 +01:00
class JSONRPCError(object):
# http://www.jsonrpc.org/specification#error_object
CODE_PARSE_ERROR = -32700 # Invalid JSON. Error while parsing the JSON text.
CODE_INVALID_REQUEST = -32600 # The JSON sent is not a valid Request object.
CODE_METHOD_NOT_FOUND = -32601 # The method does not exist / is not available.
CODE_INVALID_PARAMS = -32602 # Invalid method parameter(s).
CODE_INTERNAL_ERROR = -32603 # Internal JSON-RPC error (I think this is like a 500?)
CODE_APPLICATION_ERROR = -32500 # Generic error with our app??
CODE_AUTHENTICATION_ERROR = -32501 # Authentication failed
MESSAGES = {
CODE_PARSE_ERROR: "Parse Error. Data is not valid JSON.",
CODE_INVALID_REQUEST: "JSON data is not a valid Request",
CODE_METHOD_NOT_FOUND: "Method Not Found",
CODE_INVALID_PARAMS: "Invalid Params",
CODE_INTERNAL_ERROR: "Internal Error",
CODE_AUTHENTICATION_ERROR: "Authentication Failed",
}
HTTP_CODES = {
CODE_INVALID_REQUEST: 400,
CODE_PARSE_ERROR: 400,
CODE_INVALID_PARAMS: 400,
CODE_METHOD_NOT_FOUND: 404,
CODE_INTERNAL_ERROR: 500,
CODE_APPLICATION_ERROR: 500,
CODE_AUTHENTICATION_ERROR: 401,
}
def __init__(self, message, code=CODE_APPLICATION_ERROR, traceback=None, data=None):
assert isinstance(code, (int, long)), "'code' must be an int"
assert (data is None or isinstance(data, dict)), "'data' must be None or a dict"
self.code = code
if message is None:
message = self.MESSAGES[code] if code in self.MESSAGES else "Error"
self.message = message
self.data = {} if data is None else data
if traceback is not None:
self.data['traceback'] = traceback.split("\n")
def to_dict(self):
ret = {
'code': self.code,
'message': self.message,
}
if len(self.data):
ret['data'] = self.data
return ret
@classmethod
def create_from_exception(cls, exception, code=CODE_APPLICATION_ERROR, traceback=None):
return cls(exception.message, code=code, traceback=traceback)
def default_decimal(obj):
if isinstance(obj, Decimal):
return float(obj)
2017-01-09 20:03:29 +01:00
class UnknownAPIMethodError(Exception):
pass
class NotAllowedDuringStartupError(Exception):
pass
def trap(err, *to_trap):
err.trap(*to_trap)
2017-03-23 20:37:28 +01:00
def jsonrpc_dumps_pretty(obj, **kwargs):
try:
id_ = kwargs.pop("id")
except KeyError:
id_ = None
if isinstance(obj, JSONRPCError):
data = {"jsonrpc": "2.0", "error": obj.to_dict(), "id": id_}
else:
data = {"jsonrpc": "2.0", "result": obj, "id": id_}
return json.dumps(data, cls=jsonrpclib.JSONRPCEncoder, sort_keys=True, indent=2,
separators=(',', ': '), **kwargs) + "\n"
class JSONRPCServerType(type):
def __new__(mcs, name, bases, newattrs):
klass = type.__new__(mcs, name, bases, newattrs)
klass.callable_methods = {}
klass.deprecated_methods = {}
klass.authorized_functions = []
klass.queued_methods = []
for methodname in dir(klass):
2016-09-22 03:36:06 +02:00
if methodname.startswith("jsonrpc_"):
method = getattr(klass, methodname)
if not hasattr(method, '_deprecated'):
klass.callable_methods.update({methodname.split("jsonrpc_")[1]: method})
if hasattr(method, '_auth_required'):
klass.authorized_functions.append(methodname.split("jsonrpc_")[1])
if hasattr(method, '_queued'):
klass.queued_methods.append(methodname.split("jsonrpc_")[1])
else:
klass.deprecated_methods.update({methodname.split("jsonrpc_")[1]: method})
return klass
class AuthorizedBase(object):
__metaclass__ = JSONRPCServerType
2016-09-22 03:36:06 +02:00
@staticmethod
def auth_required(f):
f._auth_required = True
return f
2017-05-10 07:23:42 +02:00
@staticmethod
def queued(f):
f._queued = True
return f
@staticmethod
def deprecated(new_command=None):
def _deprecated_wrapper(f):
f._new_command = new_command
f._deprecated = True
return f
return _deprecated_wrapper
@staticmethod
def flags(**kwargs):
def _flag_wrapper(f):
f._flags = {}
for k, v in kwargs.iteritems():
f._flags[v] = k
return f
return _flag_wrapper
2016-09-22 03:36:06 +02:00
class AuthJSONRPCServer(AuthorizedBase):
2016-11-30 21:20:45 +01:00
"""Authorized JSONRPC server used as the base class for the LBRY API
2016-09-22 03:36:06 +02:00
API methods are named with a leading "jsonrpc_"
Decorators:
@AuthJSONRPCServer.auth_required: this requires that the client
2016-11-30 21:20:45 +01:00
include a valid hmac authentication token in their request
2016-09-22 03:36:06 +02:00
Attributes:
2016-11-30 21:20:45 +01:00
allowed_during_startup (list): list of api methods that are
callable before the server has finished startup
2016-11-30 21:20:45 +01:00
sessions (dict): dictionary of active session_id:
lbrynet.lbrynet_daemon.auth.util.APIKey values
2016-09-22 03:36:06 +02:00
authorized_functions (list): list of api methods that require authentication
callable_methods (dict): dictionary of api_callable_name: method values
2016-11-30 21:20:45 +01:00
2016-09-22 03:36:06 +02:00
"""
implements(resource.IResource)
isLeaf = True
2017-07-19 17:42:17 +02:00
allowed_during_startup = []
def __init__(self, use_authentication=None):
self._call_lock = {}
self._use_authentication = (
2017-01-17 18:31:48 +01:00
use_authentication if use_authentication is not None else conf.settings['use_auth_http']
)
2016-11-22 21:11:25 +01:00
self.announced_startup = False
self.sessions = {}
2016-09-22 03:36:06 +02:00
def setup(self):
return NotImplementedError()
2017-01-09 20:03:29 +01:00
def _set_headers(self, request, data, update_secret=False):
2017-01-17 04:23:20 +01:00
if conf.settings['allowed_origin']:
request.setHeader("Access-Control-Allow-Origin", conf.settings['allowed_origin'])
2017-03-13 16:54:40 +01:00
request.setHeader("Content-Type", "application/json")
request.setHeader("Accept", "application/json-rpc")
2017-01-09 20:03:29 +01:00
request.setHeader("Content-Length", str(len(data)))
if update_secret:
session_id = request.getSession().uid
request.setHeader(LBRY_SECRET, self.sessions.get(session_id).secret)
2017-03-23 01:27:01 +01:00
@staticmethod
def _render_message(request, message):
2017-01-09 20:03:29 +01:00
request.write(message)
request.finish()
2017-03-23 20:37:28 +01:00
def _render_error(self, failure, request, id_):
2017-03-23 01:27:01 +01:00
if isinstance(failure, JSONRPCError):
error = failure
elif isinstance(failure, Failure):
# maybe failure is JSONRPCError wrapped in a twisted Failure
error = failure.check(JSONRPCError)
if error is None:
# maybe its a twisted Failure with another type of error
error = JSONRPCError(failure.getErrorMessage(), traceback=failure.getTraceback())
else:
# last resort, just cast it as a string
error = JSONRPCError(str(failure))
2017-03-23 20:37:28 +01:00
response_content = jsonrpc_dumps_pretty(error, id=id_)
2017-03-23 01:27:01 +01:00
self._set_headers(request, response_content)
try:
request.setResponseCode(JSONRPCError.HTTP_CODES[error.code])
except KeyError:
request.setResponseCode(JSONRPCError.HTTP_CODES[JSONRPCError.CODE_INTERNAL_ERROR])
2017-03-23 01:27:01 +01:00
self._render_message(request, response_content)
@staticmethod
def _handle_dropped_request(result, d, function_name):
2017-01-09 20:03:29 +01:00
if not d.called:
log.warning("Cancelling dropped api request %s", function_name)
d.cancel()
2016-11-11 19:40:19 +01:00
2016-09-22 03:36:06 +02:00
def render(self, request):
2017-03-23 01:27:01 +01:00
try:
return self._render(request)
except BaseException as e:
log.error(e)
error = JSONRPCError.create_from_exception(e, traceback=format_exc())
self._render_error(error, request, None)
return server.NOT_DONE_YET
def _render(self, request):
2017-01-09 20:03:29 +01:00
time_in = utils.now()
2017-01-17 03:08:44 +01:00
# assert self._check_headers(request), InvalidHeaderError
2016-09-22 03:36:06 +02:00
session = request.getSession()
session_id = session.uid
2017-01-09 20:03:29 +01:00
finished_deferred = request.notifyFinish()
2016-09-22 03:36:06 +02:00
if self._use_authentication:
2016-11-14 19:53:11 +01:00
# if this is a new session, send a new secret and set the expiration
# otherwise, session.touch()
if self._initialize_session(session_id):
def expire_session():
self._unregister_user_session(session_id)
2017-01-09 20:03:25 +01:00
session.startCheckingExpiration()
session.notifyOnExpire(expire_session)
message = "OK"
2017-03-23 01:27:01 +01:00
request.setResponseCode(200)
self._set_headers(request, message, True)
self._render_message(request, message)
return server.NOT_DONE_YET
2017-01-10 01:31:06 +01:00
else:
session.touch()
2016-09-22 03:36:06 +02:00
request.content.seek(0, 0)
content = request.content.read()
try:
parsed = jsonrpclib.loads(content)
2017-01-31 00:46:06 +01:00
except ValueError:
2016-11-14 19:53:11 +01:00
log.warning("Unable to decode request json")
2017-03-23 01:27:01 +01:00
self._render_error(JSONRPCError(None, JSONRPCError.CODE_PARSE_ERROR), request, None)
2016-11-11 19:40:19 +01:00
return server.NOT_DONE_YET
2016-09-22 03:36:06 +02:00
2017-03-23 01:27:01 +01:00
id_ = None
try:
function_name = parsed.get('method')
is_queued = function_name in self.queued_methods
2017-03-23 01:27:01 +01:00
args = parsed.get('params', {})
id_ = parsed.get('id', None)
token = parsed.pop('hmac', None)
except AttributeError as err:
log.warning(err)
self._render_error(
2017-03-23 20:37:28 +01:00
JSONRPCError(None, code=JSONRPCError.CODE_INVALID_REQUEST), request, id_
2017-03-23 01:27:01 +01:00
)
return server.NOT_DONE_YET
2016-09-22 03:36:06 +02:00
reply_with_next_secret = False
if self._use_authentication:
if function_name in self.authorized_functions:
try:
self._verify_token(session_id, parsed, token)
2016-11-11 19:40:19 +01:00
except InvalidAuthenticationToken as err:
2016-11-14 19:53:11 +01:00
log.warning("API validation failed")
2016-11-30 21:20:45 +01:00
self._render_error(
2017-03-23 01:27:01 +01:00
JSONRPCError.create_from_exception(
err.message, code=JSONRPCError.CODE_AUTHENTICATION_ERROR,
traceback=format_exc()
),
2017-03-23 20:37:28 +01:00
request, id_
2017-03-23 01:27:01 +01:00
)
return server.NOT_DONE_YET
self._update_session_secret(session_id)
reply_with_next_secret = True
2016-09-22 03:36:06 +02:00
try:
function = self._get_jsonrpc_method(function_name)
2017-03-23 01:27:01 +01:00
except UnknownAPIMethodError as err:
2017-01-09 20:03:29 +01:00
log.warning('Failed to get function %s: %s', function_name, err)
2017-03-23 01:27:01 +01:00
self._render_error(
JSONRPCError(None, JSONRPCError.CODE_METHOD_NOT_FOUND),
2017-03-23 21:05:26 +01:00
request, id_
2017-03-23 01:27:01 +01:00
)
return server.NOT_DONE_YET
2017-06-22 12:10:23 +02:00
except NotAllowedDuringStartupError:
log.warning('Function not allowed during startup: %s', function_name)
2017-03-23 01:27:01 +01:00
self._render_error(
JSONRPCError("This method is unavailable until the daemon is fully started",
code=JSONRPCError.CODE_INVALID_REQUEST),
2017-03-23 21:05:26 +01:00
request, id_
2017-03-23 01:27:01 +01:00
)
2016-11-11 19:40:19 +01:00
return server.NOT_DONE_YET
2016-09-22 03:36:06 +02:00
if args == EMPTY_PARAMS or args == []:
2017-03-23 01:27:01 +01:00
args_dict = {}
2017-05-28 22:02:22 +02:00
_args, _kwargs = (), {}
elif isinstance(args, dict):
2017-03-23 01:27:01 +01:00
args_dict = args
elif len(args) == 1 and isinstance(args[0], dict):
# TODO: this is for backwards compatibility. Remove this once API and UI are updated
# TODO: also delete EMPTY_PARAMS then
2017-03-23 01:27:01 +01:00
args_dict = args[0]
2017-05-28 22:02:22 +02:00
_args, _kwargs = (), args
elif isinstance(args, list):
_args, _kwargs = args, {}
2016-11-14 19:53:11 +01:00
else:
# d = defer.maybeDeferred(function, *args) # if we want to support positional args too
raise ValueError('Args must be a dict')
2016-11-11 19:40:19 +01:00
2017-03-23 01:27:01 +01:00
params_error, erroneous_params = self._check_params(function, args_dict)
if params_error is not None:
params_error_message = '{} for {} command: {}'.format(
params_error, function_name, ', '.join(erroneous_params)
)
log.warning(params_error_message)
self._render_error(
JSONRPCError(params_error_message, code=JSONRPCError.CODE_INVALID_PARAMS),
2017-03-23 20:37:28 +01:00
request, id_
2017-03-23 01:27:01 +01:00
)
return server.NOT_DONE_YET
2017-05-10 07:23:42 +02:00
if is_queued:
d_lock = self._call_lock.get(function_name, False)
if not d_lock:
d = defer.maybeDeferred(function, self, **args_dict)
2017-05-10 07:23:42 +02:00
self._call_lock[function_name] = finished_deferred
def _del_lock(*args):
if function_name in self._call_lock:
del self._call_lock[function_name]
if args:
return args
finished_deferred.addCallback(_del_lock)
else:
log.info("queued %s", function_name)
d = d_lock
d.addBoth(lambda _: log.info("running %s from queue", function_name))
d.addCallback(lambda _: defer.maybeDeferred(function, self, **args_dict))
2017-05-10 07:23:42 +02:00
else:
d = defer.maybeDeferred(function, self, **args_dict)
2017-03-23 01:27:01 +01:00
2017-01-09 20:03:29 +01:00
# finished_deferred will callback when the request is finished
# and errback if something went wrong. If the errback is
# called, cancel the deferred stack. This is to prevent
# request.finish() from being called on a closed request.
finished_deferred.addErrback(self._handle_dropped_request, d, function_name)
2017-03-23 20:37:28 +01:00
d.addCallback(self._callback_render, request, id_, reply_with_next_secret)
2017-01-09 20:03:29 +01:00
# TODO: don't trap RuntimeError, which is presently caught to
# handle deferredLists that won't peacefully cancel, namely
# get_lbry_files
d.addErrback(trap, ConnectionDone, ConnectionLost, defer.CancelledError, RuntimeError)
2017-03-23 20:37:28 +01:00
d.addErrback(log.fail(self._render_error, request, id_),
2017-01-09 20:03:29 +01:00
'Failed to process %s', function_name)
d.addBoth(lambda _: log.debug("%s took %f",
function_name,
(utils.now() - time_in).total_seconds()))
2016-09-22 03:36:06 +02:00
return server.NOT_DONE_YET
def _register_user_session(self, session_id):
2016-09-22 03:36:06 +02:00
"""
Add or update a HMAC secret for a session
@param session_id:
@return: secret
"""
log.info("Register api session")
token = APIKey.new(seed=session_id)
self.sessions.update({session_id: token})
2016-09-22 03:36:06 +02:00
def _unregister_user_session(self, session_id):
log.info("Unregister API session")
del self.sessions[session_id]
def _check_headers(self, request):
return (
self._check_header_source(request, 'Origin') and
self._check_header_source(request, 'Referer'))
def _check_header_source(self, request, header):
"""Check if the source of the request is allowed based on the header value."""
source = request.getHeader(header)
if not self._check_source_of_request(source):
log.warning("Attempted api call from invalid %s: %s", header, source)
2016-09-22 03:36:06 +02:00
return False
return True
def _check_source_of_request(self, source):
if source is None:
return True
2017-01-17 04:23:20 +01:00
if conf.settings['api_host'] == '0.0.0.0':
return True
server, port = self.get_server_port(source)
return self._check_server_port(server, port)
def _check_server_port(self, server, port):
api = (conf.settings['api_host'], conf.settings['api_port'])
return (server, port) == api or self._is_from_allowed_origin(server, port)
def _is_from_allowed_origin(self, server, port):
allowed_origin = conf.settings['allowed_origin']
if not allowed_origin:
return False
if allowed_origin == '*':
return True
allowed_server, allowed_port = self.get_server_port(allowed_origin)
return (allowed_server, allowed_port) == (server, port)
def get_server_port(self, origin):
parsed = urlparse.urlparse(origin)
server_port = parsed.netloc.split(':')
assert len(server_port) <= 2
if len(server_port) == 2:
return server_port[0], int(server_port[1])
else:
return server_port[0], 80
2017-01-09 20:03:29 +01:00
def _verify_method_is_callable(self, function_path):
2016-09-22 03:36:06 +02:00
if function_path not in self.callable_methods:
2017-01-09 20:03:29 +01:00
raise UnknownAPIMethodError(function_path)
2016-09-22 03:36:06 +02:00
if not self.announced_startup:
if function_path not in self.allowed_during_startup:
2017-01-09 20:03:29 +01:00
raise NotAllowedDuringStartupError(function_path)
2016-09-22 03:36:06 +02:00
def _get_jsonrpc_method(self, function_path):
if function_path in self.deprecated_methods:
new_command = self.deprecated_methods[function_path]._new_command
log.warning('API function \"%s\" is deprecated, please update to use \"%s\"',
function_path, new_command)
function_path = new_command
2017-01-09 20:03:29 +01:00
self._verify_method_is_callable(function_path)
2016-09-22 03:36:06 +02:00
return self.callable_methods.get(function_path)
2017-05-28 22:02:22 +02:00
@staticmethod
def _check_params(function, args_dict):
argspec = inspect.getargspec(undecorated(function))
num_optional_params = 0 if argspec.defaults is None else len(argspec.defaults)
missing_required_params = [
required_param
for required_param in argspec.args[1:-num_optional_params]
if required_param not in args_dict
]
if len(missing_required_params):
return 'Missing required parameters', missing_required_params
extraneous_params = [] if argspec.keywords is not None else [
extra_param
for extra_param in args_dict
if extra_param not in argspec.args[1:]
]
if len(extraneous_params):
return 'Extraneous parameters', extraneous_params
return None, None
2016-09-22 03:36:06 +02:00
def _initialize_session(self, session_id):
if not self.sessions.get(session_id, False):
self._register_user_session(session_id)
2016-09-22 03:36:06 +02:00
return True
return False
2016-09-22 03:36:06 +02:00
def _verify_token(self, session_id, message, token):
2017-03-23 01:27:01 +01:00
if token is None:
raise InvalidAuthenticationToken('Authentication token not found')
2016-09-22 03:36:06 +02:00
to_auth = get_auth_message(message)
api_key = self.sessions.get(session_id)
2017-03-23 01:27:01 +01:00
if not api_key.compare_hmac(to_auth, token):
raise InvalidAuthenticationToken('Invalid authentication token')
2016-09-22 03:36:06 +02:00
def _update_session_secret(self, session_id):
self.sessions.update({session_id: APIKey.new(name=session_id)})
2017-03-23 20:37:28 +01:00
def _callback_render(self, result, request, id_, auth_required=False):
try:
2017-03-23 20:37:28 +01:00
encoded_message = jsonrpc_dumps_pretty(result, id=id_, default=default_decimal)
2017-03-23 01:27:01 +01:00
request.setResponseCode(200)
2016-09-22 03:36:06 +02:00
self._set_headers(request, encoded_message, auth_required)
self._render_message(request, encoded_message)
2016-11-11 19:40:19 +01:00
except Exception as err:
log.exception("Failed to render API response: %s", result)
2017-03-23 20:37:28 +01:00
self._render_error(err, request, id_)
2017-01-31 00:46:06 +01:00
@staticmethod
def _render_response(result):
2017-01-11 21:31:08 +01:00
return defer.succeed(result)