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

494 lines
19 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
2018-05-29 20:36:00 +02: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:
2017-12-06 19:25:58 +01:00
message = self.MESSAGES[code] if code in self.MESSAGES else "API Error"
2017-03-23 01:27:01 +01:00
self.message = message
self.data = {} if data is None else data
2017-12-06 19:25:58 +01:00
self.traceback = []
2017-03-23 01:27:01 +01:00
if traceback is not None:
2017-12-06 19:25:58 +01:00
trace_lines = traceback.split("\n")
for i, t in enumerate(trace_lines):
if "--- <exception caught here> ---" in t:
if len(trace_lines) > i + 1:
self.traceback = [j for j in trace_lines[i+1:] if j]
break
2017-03-23 01:27:01 +01:00
def to_dict(self):
2017-12-06 19:25:58 +01:00
return {
2017-03-23 01:27:01 +01:00
'code': self.code,
'message': self.message,
2017-12-06 19:25:58 +01:00
'data': self.traceback
2017-03-23 01:27:01 +01:00
}
@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 = {}
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})
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 deprecated(new_command=None):
def _deprecated_wrapper(f):
f._new_command = new_command
f._deprecated = True
return f
return _deprecated_wrapper
2016-09-22 03:36:06 +02:00
class AuthJSONRPCServer(AuthorizedBase):
"""
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_"
Attributes:
allowed_during_startup (list): list of api methods that are callable before the server has finished startup
sessions (dict): (dict): {<session id>: <lbrynet.daemon.auth.util.APIKey>}
callable_methods (dict): {<api method name>: <api method>}
Authentication:
If use_authentication is true, basic HTTP and HMAC authentication will be used for all requests and the
service url will require a username and password.
To start an authenticated session a client sends an HTTP POST to <user>:<password>@<api host>:<api port>.
If accepted, the server replies with a TWISTED_SESSION cookie containing a session id and the message "OK".
The client initializes their shared secret for hmac to be the b64 encoded sha256 of their session id.
To send an authenticated request a client sends an HTTP POST to the auth api url with the TWISTED_SESSION
cookie and includes a hmac token in the message using the previously set shared secret. If the token is valid
the server will randomize the shared secret and return the new value under the LBRY_SECRET header, which the
client uses to generate the token for their next request.
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._use_authentication = use_authentication or 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
2017-12-06 19:25:58 +01:00
error = JSONRPCError(failure.getErrorMessage() or failure.type.__name__,
traceback=failure.getTraceback())
2017-03-23 01:27:01 +01:00
else:
# last resort, just cast it as a string
error = JSONRPCError(str(failure))
log.warning("error processing api request: %s\ntraceback: %s", error.message,
"\n".join(error.traceback))
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)
2017-12-06 19:25:58 +01:00
request.setResponseCode(200)
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()
2018-05-28 16:57:48 +02:00
# if not self._check_headers(request):
# self._render_error(Failure(InvalidHeaderError()), request, None)
# return server.NOT_DONE_YET
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
request_id = None
2017-03-23 01:27:01 +01:00
try:
function_name = parsed.get('method')
args = parsed.get('params', {})
request_id = parsed.get('id', None)
2017-03-23 01:27:01 +01:00
token = parsed.pop('hmac', None)
except AttributeError as err:
log.warning(err)
self._render_error(
JSONRPCError(None, code=JSONRPCError.CODE_INVALID_REQUEST), 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:
try:
self._verify_token(session_id, parsed, token)
except InvalidAuthenticationToken as err:
log.warning("API validation failed")
self._render_error(
JSONRPCError.create_from_exception(
err, code=JSONRPCError.CODE_AUTHENTICATION_ERROR,
traceback=format_exc()
),
request, request_id
)
return server.NOT_DONE_YET
request.addCookie("TWISTED_SESSION", session_id)
self._update_session_secret(session_id)
reply_with_next_secret = True
2016-09-22 03:36:06 +02:00
try:
2017-11-05 01:26:11 +01:00
fn = 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),
request, 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),
request, 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-05-28 22:02:22 +02:00
_args, _kwargs = (), {}
elif isinstance(args, dict):
2018-04-03 18:26:32 +02:00
_args, _kwargs = (), 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
2018-04-19 17:00:44 +02:00
_args, _kwargs = (), args[0]
2018-04-03 18:26:32 +02:00
elif len(args) == 2 and isinstance(args[0], list) and isinstance(args[1], dict):
_args, _kwargs = args
2016-11-14 19:53:11 +01:00
else:
2018-04-03 18:26:32 +02:00
raise ValueError('invalid args format')
2016-11-11 19:40:19 +01:00
2018-04-03 18:26:32 +02:00
params_error, erroneous_params = self._check_params(fn, _args, _kwargs)
2017-03-23 01:27:01 +01:00
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),
request, request_id
2017-03-23 01:27:01 +01:00
)
return server.NOT_DONE_YET
2018-04-03 18:26:32 +02:00
d = defer.maybeDeferred(fn, self, *_args, **_kwargs)
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)
d.addCallback(self._callback_render, request, request_id, reply_with_next_secret)
d.addErrback(trap, ConnectionDone, ConnectionLost, defer.CancelledError)
d.addErrback(self._render_error, request, request_id)
2017-01-09 20:03:29 +01:00
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("Started new 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)
if not self.announced_startup:
if function_path not in self.allowed_during_startup:
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
2018-04-03 18:26:32 +02:00
def _check_params(function, args_tup, args_dict):
2017-05-28 22:02:22 +02:00
argspec = inspect.getargspec(undecorated(function))
num_optional_params = 0 if argspec.defaults is None else len(argspec.defaults)
2018-04-03 18:26:32 +02:00
duplicate_params = [
duplicate_param
2018-04-03 19:08:36 +02:00
for duplicate_param in argspec.args[1:len(args_tup) + 1]
2018-04-03 18:26:32 +02:00
if duplicate_param in args_dict
]
if duplicate_params:
return 'Duplicate parameters', duplicate_params
2017-05-28 22:02:22 +02:00
missing_required_params = [
required_param
2018-04-03 18:26:32 +02:00
for required_param in argspec.args[len(args_tup)+1:-num_optional_params]
2017-05-28 22:02:22 +02:00
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):
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)