diff --git a/ChangeLog b/ChangeLog index 2a2083bd5..df7a83232 100644 --- a/ChangeLog +++ b/ChangeLog @@ -3,6 +3,8 @@ * Removed the AutoAdd feature on the core. It's now handled with the AutoAdd plugin, which is also shipped with Deluge, and it does a better job and now, it even supports multiple users perfectly. + * Authentication/Permission exceptions are now sent to clients and recreated + there to allow acting upon them. ==== Core ==== * Implement #1063 option to delete torrent file copy on torrent removal - patch from Ghent @@ -20,10 +22,16 @@ * File modifications on the auth file are now detected and when they happen, the file is reloaded. Upon finding an old auth file with an old format, an upgrade to the new format is made, file saved, and reloaded. + * Authentication no longer requires a username/password. If one or both of + these is missing, an authentication error will be sent to the client + which sould then ask the username/password to the user. ==== GtkUI ==== * Fix uncaught exception when closing deluge in classic mode * Allow changing ownership of torrents + * Host entries in the Connection Manager UI are now editable. They're + now also migrated from the old format were automatic localhost logins were + possible, which no longer is, this fixes #1814. ==== WebUI ==== * Migrate to ExtJS 3.1 diff --git a/deluge/core/rpcserver.py b/deluge/core/rpcserver.py index 60e190593..9e8823279 100644 --- a/deluge/core/rpcserver.py +++ b/deluge/core/rpcserver.py @@ -56,7 +56,7 @@ except ImportError: import deluge.component as component import deluge.configmanager from deluge.core.authmanager import AUTH_LEVEL_NONE, AUTH_LEVEL_DEFAULT, AUTH_LEVEL_ADMIN -from deluge.error import DelugeError, NotAuthorizedError, AuthenticationRequired +from deluge.error import DelugeError, NotAuthorizedError, __PassthroughError RPC_RESPONSE = 1 RPC_ERROR = 2 @@ -219,6 +219,9 @@ class DelugeRPCProtocol(Protocol): log.info("Deluge client disconnected: %s", reason.value) + def valid_session(self): + return self.transport.sessionno in self.factory.authorized_sessions + def dispatch(self, request_id, method, args, kwargs): """ This method is run when a RPC Request is made. It will run the local method @@ -262,18 +265,23 @@ class DelugeRPCProtocol(Protocol): if ret: self.factory.authorized_sessions[self.transport.sessionno] = (ret, args[0]) self.factory.session_protocols[self.transport.sessionno] = self - except AuthenticationRequired, err: - self.sendData((RPC_EVENT_AUTH, request_id, err.message, args[0])) except Exception, e: - sendError() - log.exception(e) + if isinstance(e, __PassthroughError): + self.sendData( + (RPC_EVENT_AUTH, request_id, + e.__class__.__name__, + e._args, e._kwargs, args[0]) + ) + else: + sendError() + log.exception(e) else: self.sendData((RPC_RESPONSE, request_id, (ret))) if not ret: self.transport.loseConnection() finally: return - elif method == "daemon.set_event_interest" and self.transport.sessionno in self.factory.authorized_sessions: + elif method == "daemon.set_event_interest" and self.valid_session(): log.debug("RPC dispatch daemon.set_event_interest") # This special case is to allow clients to set which events they are # interested in receiving. @@ -289,22 +297,24 @@ class DelugeRPCProtocol(Protocol): finally: return - if method in self.factory.methods and self.transport.sessionno in self.factory.authorized_sessions: + if method in self.factory.methods and self.valid_session(): log.debug("RPC dispatch %s", method) try: method_auth_requirement = self.factory.methods[method]._rpcserver_auth_level auth_level = self.factory.authorized_sessions[self.transport.sessionno][0] if auth_level < method_auth_requirement: # This session is not allowed to call this method - log.debug("Session %s is trying to call a method it is not authorized to call!", self.transport.sessionno) - raise NotAuthorizedError("Auth level too low: %s < %s" % (auth_level, method_auth_requirement)) + log.debug("Session %s is trying to call a method it is not " + "authorized to call!", self.transport.sessionno) + raise NotAuthorizedError(auth_level, method_auth_requirement) # Set the session_id in the factory so that methods can know # which session is calling it. self.factory.session_id = self.transport.sessionno ret = self.factory.methods[method](*args, **kwargs) except Exception, e: sendError() - # Don't bother printing out DelugeErrors, because they are just for the client + # Don't bother printing out DelugeErrors, because they are just + # for the client if not isinstance(e, DelugeError): log.exception("Exception calling RPC request: %s", e) else: @@ -545,8 +555,12 @@ def generate_ssl_keys(): # Write out files ssl_dir = deluge.configmanager.get_config_dir("ssl") - open(os.path.join(ssl_dir, "daemon.pkey"), "w").write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)) - open(os.path.join(ssl_dir, "daemon.cert"), "w").write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) + open(os.path.join(ssl_dir, "daemon.pkey"), "w").write( + crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) + ) + open(os.path.join(ssl_dir, "daemon.cert"), "w").write( + crypto.dump_certificate(crypto.FILETYPE_PEM, cert) + ) # Make the files only readable by this user for f in ("daemon.pkey", "daemon.cert"): os.chmod(os.path.join(ssl_dir, f), stat.S_IREAD | stat.S_IWRITE) diff --git a/deluge/error.py b/deluge/error.py index cdb67091a..95d00fce8 100644 --- a/deluge/error.py +++ b/deluge/error.py @@ -50,11 +50,24 @@ class InvalidTorrentError(DelugeError): class InvalidPathError(DelugeError): pass -class NotAuthorizedError(DelugeError): - pass +class __PassthroughError(DelugeError): + def __new__(cls, *args, **kwargs): + inst = super(__PassthroughError, cls).__new__(cls, *args, **kwargs) + inst._args = args + inst._kwargs = kwargs + return inst + +class NotAuthorizedError(__PassthroughError): + def __init__(self, current_level, required_level): + self.message = _( + "Auth level too low: %(current_level)s < %(required_level)s" % + dict(current_level=current_level, required_level=required_level) + ) + self.current_level = current_level + self.required_level = required_level -class _UsernameBasedException(DelugeError): +class __UsernameBasedPasstroughError(__PassthroughError): def _get_message(self): return self._message @@ -71,16 +84,16 @@ class _UsernameBasedException(DelugeError): del _get_username, _set_username def __init__(self, message, username): - super(_UsernameBasedException, self).__init__(message) + super(__UsernameBasedPasstroughError, self).__init__(message) self.message = message self.username = username -class BadLoginError(_UsernameBasedException): +class BadLoginError(__UsernameBasedPasstroughError): pass -class AuthenticationRequired(BadLoginError): +class AuthenticationRequired(__UsernameBasedPasstroughError): pass -class AuthManagerError(_UsernameBasedException): +class AuthManagerError(__UsernameBasedPasstroughError): pass diff --git a/deluge/tests/common.py b/deluge/tests/common.py index 84722c560..78d1739b8 100644 --- a/deluge/tests/common.py +++ b/deluge/tests/common.py @@ -1,6 +1,8 @@ import os - +import sys +import time import tempfile +from subprocess import Popen, PIPE import deluge.configmanager import deluge.log @@ -31,3 +33,37 @@ try: gettext.install("deluge", pkg_resources.resource_filename("deluge", "i18n")) except Exception, e: print e + +def start_core(): + CWD = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + DAEMON_SCRIPT = """ +import sys +import deluge.main + +sys.argv.extend(['-d', '-c', '%s', '-L', 'info']) + +deluge.main.start_daemon() +""" + config_directory = set_tmp_config_dir() + fp = tempfile.TemporaryFile() + fp.write(DAEMON_SCRIPT % config_directory) + fp.seek(0) + + core = Popen([sys.executable], cwd=CWD, stdin=fp, stdout=PIPE, stderr=PIPE) + while True: + line = core.stderr.readline() + if "Factory starting on 58846" in line: + time.sleep(0.3) # Slight pause just incase + break + elif 'Traceback' in line: + raise SystemExit( + "Failed to start core daemon. Do \"\"\"%s\"\"\" to see what's " + "happening" % + "python -c \"import sys; import tempfile; " + "config_directory = tempfile.mkdtemp(); " + "import deluge.main; import deluge.configmanager; " + "deluge.configmanager.set_config_dir(config_directory); " + "sys.argv.extend(['-d', '-c', config_directory, '-L', 'info']); " + "deluge.main.start_daemon()" + ) + return core diff --git a/deluge/tests/test_authmanager.py b/deluge/tests/test_authmanager.py index 617984664..08bbab662 100644 --- a/deluge/tests/test_authmanager.py +++ b/deluge/tests/test_authmanager.py @@ -2,7 +2,7 @@ from twisted.trial import unittest import common -from deluge.core.authmanager import AuthManager +from deluge.core.authmanager import AuthManager, AUTH_LEVEL_ADMIN class AuthManagerTestCase(unittest.TestCase): def setUp(self): @@ -11,4 +11,7 @@ class AuthManagerTestCase(unittest.TestCase): def test_authorize(self): from deluge.ui import common - self.assertEquals(self.auth.authorize(*common.get_localhost_auth()), 10) + self.assertEquals( + self.auth.authorize(*common.get_localhost_auth()), + AUTH_LEVEL_ADMIN + ) diff --git a/deluge/tests/test_client.py b/deluge/tests/test_client.py index 8750d5c06..82171ee0c 100644 --- a/deluge/tests/test_client.py +++ b/deluge/tests/test_client.py @@ -1,53 +1,80 @@ -import os -import sys -import time -import signal -import tempfile - -from subprocess import Popen, PIPE import common from twisted.trial import unittest +from deluge import error +from deluge.core.authmanager import AUTH_LEVEL_ADMIN, AUTH_LEVEL_DEFAULT from deluge.ui.client import client -CWD = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - -DAEMON_SCRIPT = """ -import sys -import deluge.main - -sys.argv.extend(['-d', '-c', '%s', '-Linfo']) - -deluge.main.start_daemon() -""" - class ClientTestCase(unittest.TestCase): def setUp(self): - config_directory = common.set_tmp_config_dir() - - fp = tempfile.TemporaryFile() - fp.write(DAEMON_SCRIPT % config_directory) - fp.seek(0) - - self.core = Popen([sys.executable], cwd=CWD, - stdin=fp, stdout=PIPE, stderr=PIPE) - - time.sleep(2) # Slight pause just incase + self.core = common.start_core() def tearDown(self): self.core.terminate() def test_connect_no_credentials(self): - return # hack whilst core is broken + d = client.connect("localhost", 58846) - d.addCallback(self.assertEquals, 10) + + def on_failure(failure): + self.assertEqual( + failure.trap(error.AuthenticationRequired), + error.AuthenticationRequired + ) + self.addCleanup(client.disconnect) + + d.addErrback(on_failure) + return d + + def test_connect_localclient(self): + from deluge.ui import common + username, password = common.get_localhost_auth() + d = client.connect( + "localhost", 58846, username=username, password=password + ) def on_connect(result): + self.assertEqual(client.get_auth_level(), AUTH_LEVEL_ADMIN) self.addCleanup(client.disconnect) return result d.addCallback(on_connect) return d + + def test_connect_bad_password(self): + from deluge.ui import common + username, password = common.get_localhost_auth() + d = client.connect( + "localhost", 58846, username=username, password=password+'1' + ) + + def on_failure(failure): + self.assertEqual( + failure.trap(error.BadLoginError), + error.BadLoginError + ) + self.addCleanup(client.disconnect) + + d.addErrback(on_failure) + return d + + def test_connect_without_password(self): + from deluge.ui import common + username, password = common.get_localhost_auth() + d = client.connect( + "localhost", 58846, username=username + ) + + def on_failure(failure): + self.assertEqual( + failure.trap(error.AuthenticationRequired), + error.AuthenticationRequired + ) + self.assertEqual(failure.value.username, username) + self.addCleanup(client.disconnect) + + d.addErrback(on_failure) + return d diff --git a/deluge/tests/test_core.py b/deluge/tests/test_core.py index 57991681c..751851325 100644 --- a/deluge/tests/test_core.py +++ b/deluge/tests/test_core.py @@ -13,11 +13,14 @@ except ImportError: import os import common +import warnings rpath = common.rpath from deluge.core.rpcserver import RPCServer from deluge.core.core import Core +warnings.filterwarnings("ignore", category=RuntimeWarning) from deluge.ui.web.common import compress +warnings.resetwarnings() import deluge.component as component import deluge.error diff --git a/deluge/tests/test_httpdownloader.py b/deluge/tests/test_httpdownloader.py index 4dbed91e2..76d89cd6e 100644 --- a/deluge/tests/test_httpdownloader.py +++ b/deluge/tests/test_httpdownloader.py @@ -1,4 +1,5 @@ import os +import warnings from twisted.trial import unittest from twisted.internet import reactor @@ -9,7 +10,10 @@ from twisted.web.server import Site from deluge.httpdownloader import download_file from deluge.log import setupLogger + +warnings.filterwarnings("ignore", category=RuntimeWarning) from deluge.ui.web.common import compress +warnings.resetwarnings() from email.utils import formatdate diff --git a/deluge/ui/client.py b/deluge/ui/client.py index 049595383..e59df470d 100644 --- a/deluge/ui/client.py +++ b/deluge/ui/client.py @@ -45,8 +45,8 @@ except ImportError: import zlib import deluge.common +from deluge import error from deluge.log import LOG as log -from deluge.error import AuthenticationRequired from deluge.event import known_events if deluge.common.windows_check(): @@ -206,7 +206,8 @@ class DelugeRPCProtocol(Protocol): # Run the callbacks registered with this Deferred object d.callback(request[2]) elif message_type == RPC_EVENT_AUTH: - d.errback(AuthenticationRequired(request[2], request[3])) + # Recreate exception and errback'it + d.errback(getattr(error, request[2])(*request[3], **request[4])) elif message_type == RPC_ERROR: # Create the DelugeRPCError to pass to the errback r = self.__rpc_requests[request_id] @@ -416,13 +417,16 @@ class DaemonSSLProxy(DaemonProxy): containing a `:class:DelugeRPCError` object. """ try: - if error_data.check(AuthenticationRequired): + if isinstance(error_data.value, error.NotAuthorizedError): + # Still log these errors + log.error(error_data.value.logable()) + return error_data + if isinstance(error_data.value, error.__PassthroughError): return error_data except: pass - if error_data.value.exception_type != 'AuthManagerError': - log.error(error_data.value.logable()) + log.error(error_data.value.logable()) return error_data def __on_connect(self, result): diff --git a/deluge/ui/gtkui/connectionmanager.py b/deluge/ui/gtkui/connectionmanager.py index 6a1395a65..bbe7e42e8 100644 --- a/deluge/ui/gtkui/connectionmanager.py +++ b/deluge/ui/gtkui/connectionmanager.py @@ -43,6 +43,7 @@ from twisted.internet import reactor import deluge.component as component import common import deluge.configmanager +from deluge.ui.common import get_localhost_auth from deluge.ui.client import client import deluge.ui.client from deluge.configmanager import ConfigManager @@ -100,6 +101,7 @@ class ConnectionManager(component.Component): self.gtkui_config = ConfigManager("gtkui.conf") self.config = ConfigManager("hostlist.conf.1.2", DEFAULT_CONFIG) + self.config.run_converter((0, 1), 2, self.__migrate_config_1_to_2) self.running = False @@ -483,8 +485,9 @@ class ConnectionManager(component.Component): dialog.get_password()) d = dialog.run().addCallback(dialog_finished, host, port, user) return d - dialogs.ErrorDialog(_("Failed To Authenticate"), - reason.value.exception_msg).run() + dialogs.ErrorDialog( + _("Failed To Authenticate"), reason.value.message + ).run() def on_button_connect_clicked(self, widget=None): model, row = self.hostlist.get_selection().get_selected() @@ -683,3 +686,16 @@ class ConnectionManager(component.Component): def on_askpassword_dialog_entry_activate(self, entry): self.askpassword_dialog.response(gtk.RESPONSE_OK) + + def __migrate_config_1_to_2(self, config): + localclient_username, localclient_password = get_localhost_auth() + if not localclient_username: + # Nothing to do here, there's no auth file + return + for idx, (_, host, _, username, _) in enumerate(config["hosts"][:]): + if host in ("127.0.0.1", "localhost"): + if not username: + config["hosts"][idx][3] = localclient_username + config["hosts"][idx][4] = localclient_password + return config + diff --git a/deluge/ui/web/common.py b/deluge/ui/web/common.py index b21bfd8aa..e3795fa1b 100644 --- a/deluge/ui/web/common.py +++ b/deluge/ui/web/common.py @@ -35,7 +35,6 @@ import zlib import gettext -from mako.template import Template as MakoTemplate from deluge import common _ = lambda x: gettext.gettext(x).decode("utf-8") @@ -59,18 +58,31 @@ def compress(contents, request): contents += compress.flush() return contents -class Template(MakoTemplate): - """ - A template that adds some built-ins to the rendering - """ - - builtins = { - "_": _, - "escape": escape, - "version": common.get_version() - } - - def render(self, *args, **data): - data.update(self.builtins) - rendered = MakoTemplate.render_unicode(self, *args, **data) - return rendered.encode('utf-8', 'replace') +try: + # This is beeing done like this in order to allow tests to use the above + # `compress` without requiring Mako to be instaled + from mako.template import Template as MakoTemplate + class Template(MakoTemplate): + """ + A template that adds some built-ins to the rendering + """ + + builtins = { + "_": _, + "escape": escape, + "version": common.get_version() + } + + def render(self, *args, **data): + data.update(self.builtins) + rendered = MakoTemplate.render_unicode(self, *args, **data) + return rendered.encode('utf-8', 'replace') +except ImportError: + import warnings + warnings.warn("The Mako library is required to run deluge.ui.web", + RuntimeWarning) + class Template(object): + def __new__(cls, *args, **kwargs): + raise RuntimeError( + "The Mako library is required to run deluge.ui.web" + )