From patchwork Fri Jun 10 02:35:31 2011 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Michael-Doyle Hudson X-Patchwork-Id: 1800 Return-Path: Delivered-To: unknown Received: from imap.gmail.com (74.125.45.109) by localhost6.localdomain6 with IMAP4-SSL; 10 Jun 2011 20:11:49 -0000 Delivered-To: patches@linaro.org Received: by 10.52.181.10 with SMTP id ds10cs285985vdc; Thu, 9 Jun 2011 19:35:36 -0700 (PDT) Received: by 10.227.2.81 with SMTP id 17mr1578039wbi.15.1307673335081; Thu, 09 Jun 2011 19:35:35 -0700 (PDT) Received: from adelie.canonical.com (adelie.canonical.com [91.189.90.139]) by mx.google.com with ESMTP id p1si5588544wbh.4.2011.06.09.19.35.34; Thu, 09 Jun 2011 19:35:35 -0700 (PDT) Received-SPF: pass (google.com: best guess record for domain of bounces@canonical.com designates 91.189.90.139 as permitted sender) client-ip=91.189.90.139; Authentication-Results: mx.google.com; spf=pass (google.com: best guess record for domain of bounces@canonical.com designates 91.189.90.139 as permitted sender) smtp.mail=bounces@canonical.com Received: from loganberry.canonical.com ([91.189.90.37]) by adelie.canonical.com with esmtp (Exim 4.71 #1 (Debian)) id 1QUrZM-0008OM-Sb for ; Fri, 10 Jun 2011 02:35:33 +0000 Received: from loganberry.canonical.com (localhost [127.0.0.1]) by loganberry.canonical.com (Postfix) with ESMTP id 78C032E802C for ; Fri, 10 Jun 2011 02:35:31 +0000 (UTC) MIME-Version: 1.0 X-Launchpad-Project: lava-tool X-Launchpad-Branch: ~linaro-validation/lava-tool/trunk X-Launchpad-Message-Rationale: Subscriber X-Launchpad-Branch-Revision-Number: 152 X-Launchpad-Notification-Type: branch-revision To: Linaro Patch Tracker From: noreply@launchpad.net Subject: [Branch ~linaro-validation/lava-tool/trunk] Rev 152: add an auth-add command that stores a token for a particular site in the keyring Message-Id: <20110610023531.4331.33365.launchpad@loganberry.canonical.com> Date: Fri, 10 Jun 2011 02:35:31 -0000 Reply-To: noreply@launchpad.net Sender: bounces@canonical.com Errors-To: bounces@canonical.com Precedence: bulk X-Generated-By: Launchpad (canonical.com); Revision="13175"; Instance="initZopeless config overlay" X-Launchpad-Hash: cf419386cf7f7331f53ea19768204b3ca0d04ad5 Merge authors: Michael Hudson-Doyle (mwhudson) Related merge proposals: https://code.launchpad.net/~mwhudson/lava-tool/auth-support/+merge/63806 proposed by: Michael Hudson-Doyle (mwhudson) review: Approve - Zygmunt Krynicki (zkrynicki) ------------------------------------------------------------ revno: 152 [merge] committer: Michael-Doyle Hudson branch nick: trunk timestamp: Fri 2011-06-10 14:34:44 +1200 message: add an auth-add command that stores a token for a particular site in the keyring added: lava_tool/authtoken.py lava_tool/commands/auth.py lava_tool/tests/test_auth_commands.py lava_tool/tests/test_authtoken.py modified: lava_tool/tests/__init__.py setup.py --- lp:lava-tool https://code.launchpad.net/~linaro-validation/lava-tool/trunk You are subscribed to branch lp:lava-tool. To unsubscribe from this branch go to https://code.launchpad.net/~linaro-validation/lava-tool/trunk/+edit-subscription === added file 'lava_tool/authtoken.py' --- lava_tool/authtoken.py 1970-01-01 00:00:00 +0000 +++ lava_tool/authtoken.py 2011-06-08 03:49:30 +0000 @@ -0,0 +1,112 @@ +# Copyright (C) 2011 Linaro Limited +# +# Author: Michael Hudson-Doyle +# +# This file is part of lava-tool. +# +# lava-tool is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation +# +# lava-tool is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with lava-tool. If not, see . + +import base64 +import urllib +import xmlrpclib + +import keyring.core + +from lava_tool.interface import LavaCommandError + +class AuthBackend(object): + + def add_token(self, username, hostname, token): + raise NotImplementedError + + def get_token_for_host(self, user, host): + raise NotImplementedError + + +class KeyringAuthBackend(AuthBackend): + + def add_token(self, username, hostname, token): + keyring.core.set_password("lava-tool-%s" % hostname, username, token) + + def get_token_for_host(self, username, hostname): + return keyring.core.get_password("lava-tool-%s" % hostname, username) + + +class MemoryAuthBackend(AuthBackend): + + def __init__(self, user_host_token_list): + self._tokens = {} + for user, host, token in user_host_token_list: + self._tokens[(user, host)] = token + + def add_token(self, username, hostname, token): + self._tokens[(username, hostname)] = token + + def get_token_for_host(self, username, host): + return self._tokens.get((username, host)) + + +class AuthenticatingTransportMixin: + + def get_host_info(self, host): + + x509 = {} + if isinstance(host, tuple): + host, x509 = host + + auth, host = urllib.splituser(host) + + if auth: + user, token = urllib.splitpasswd(auth) + if token is None: + token = self.auth_backend.get_token_for_host(user, host) + if token is None: + raise LavaCommandError( + "Username provided but no token found.") + auth = base64.b64encode(urllib.unquote(user + ':' + token)) + extra_headers = [ + ("Authorization", "Basic " + auth) + ] + else: + extra_headers = None + + return host, extra_headers, x509 + + +class AuthenticatingTransport( + AuthenticatingTransportMixin, xmlrpclib.Transport): + def __init__(self, use_datetime=0, auth_backend=None): + xmlrpclib.Transport.__init__(self, use_datetime) + self.auth_backend = auth_backend + + +class AuthenticatingSafeTransport( + AuthenticatingTransportMixin, xmlrpclib.SafeTransport): + def __init__(self, use_datetime=0, auth_backend=None): + xmlrpclib.SafeTransport.__init__(self, use_datetime) + self.auth_backend = auth_backend + + +class AuthenticatingServerProxy(xmlrpclib.ServerProxy): + + def __init__(self, uri, transport=None, encoding=None, verbose=0, + allow_none=0, use_datetime=0, auth_backend=None): + if transport is None: + if urllib.splittype(uri)[0] == "https": + transport = AuthenticatingSafeTransport( + use_datetime=use_datetime, auth_backend=auth_backend) + else: + transport = AuthenticatingTransport( + use_datetime=use_datetime, auth_backend=auth_backend) + xmlrpclib.ServerProxy.__init__( + self, uri, transport, encoding, verbose, allow_none, use_datetime) === added file 'lava_tool/commands/auth.py' --- lava_tool/commands/auth.py 1970-01-01 00:00:00 +0000 +++ lava_tool/commands/auth.py 2011-06-09 05:35:17 +0000 @@ -0,0 +1,123 @@ +# Copyright (C) 2011 Linaro Limited +# +# Author: Michael Hudson-Doyle +# +# This file is part of lava-tool. +# +# lava-tool is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation +# +# lava-tool is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with lava-tool. If not, see . + +import argparse +import getpass +import urlparse +import xmlrpclib + +from lava_tool.authtoken import ( + AuthenticatingServerProxy, + KeyringAuthBackend, + MemoryAuthBackend, + ) +from lava_tool.interface import Command, LavaCommandError + + +def normalize_xmlrpc_url(uri): + if '://' not in uri: + uri = 'http://' + uri + if not uri.endswith('/'): + uri += '/' + if not uri.endswith('/RPC2/'): + uri += 'RPC2/' + return uri + + +class auth_add(Command): + """ + Add an authentication token. + """ + + def __init__(self, parser, args, auth_backend=None): + super(auth_add, self).__init__(parser, args) + if auth_backend is None: + auth_backend = KeyringAuthBackend() + self.auth_backend = auth_backend + + @classmethod + def register_arguments(cls, parser): + super(auth_add, cls).register_arguments(parser) + parser.add_argument( + "HOST", + help=("Endpoint to add token for, in the form " + "scheme://username@host. The username will default to " + "the currently logged in user.")) + parser.add_argument( + "--token-file", default=None, + help="Read the secret from here rather than prompting for it.") + parser.add_argument( + "--no-check", action='store_true', + help=("By default, a call to the remote server is made to check " + "that the added token works before remembering it. " + "Passing this option prevents this check.")) + + def invoke(self): + uri = normalize_xmlrpc_url(self.args.HOST) + parsed_host = urlparse.urlparse(uri) + + if parsed_host.username: + username = parsed_host.username + else: + username = getpass.getuser() + + host = parsed_host.hostname + if parsed_host.port: + host += ':' + str(parsed_host.port) + + uri = '%s://%s@%s/RPC2/' % (parsed_host.scheme, username, host) + + if self.args.token_file: + if parsed_host.password: + raise LavaCommandError( + "Token specified in url but --token-file also passed.") + else: + try: + token_file = open(self.args.token_file) + except IOError as ex: + raise LavaCommandError("opening %r failed: %s" % (self.args.token_file, ex)) + token = token_file.read() + else: + if parsed_host.password: + token = parsed_host.password + else: + token = getpass.getpass("Paste token for %s: " % uri) + + if not self.args.no_check: + sp = AuthenticatingServerProxy( + uri, auth_backend=MemoryAuthBackend( + [(username, host, token)])) + try: + token_user = sp.system.whoami() + except xmlrpclib.ProtocolError as ex: + if ex.errcode == 401: + raise LavaCommandError( + "Token rejected by server for user %s." % username) + else: + raise + except xmlrpclib.Fault as ex: + raise LavaCommandError( + "Server reported error during check: %s." % ex) + if token_user != username: + raise LavaCommandError( + "whoami() returned %s rather than expected %s -- this is " + "a bug." % (token_user, username)) + + self.auth_backend.add_token(username, host, token) + + print 'Token added successfully for user %s.' % username === modified file 'lava_tool/tests/__init__.py' --- lava_tool/tests/__init__.py 2011-05-04 01:12:16 +0000 +++ lava_tool/tests/__init__.py 2011-06-08 01:47:44 +0000 @@ -35,6 +35,8 @@ def test_modules(): return [ + 'lava_tool.tests.test_authtoken', + 'lava_tool.tests.test_auth_commands', 'lava_tool.tests.test_commands', ] === added file 'lava_tool/tests/test_auth_commands.py' --- lava_tool/tests/test_auth_commands.py 1970-01-01 00:00:00 +0000 +++ lava_tool/tests/test_auth_commands.py 2011-06-09 05:35:17 +0000 @@ -0,0 +1,202 @@ +# Copyright (C) 2011 Linaro Limited +# +# Author: Michael Hudson-Doyle +# +# This file is part of lava-tool. +# +# lava-tool is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation +# +# lava-tool is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with lava-tool. If not, see . + +""" +Unit tests for the lava_tool.commands.auth package +""" + +import StringIO +import sys +import tempfile +import xmlrpclib + +from lava_tool.authtoken import MemoryAuthBackend +from lava_tool.mocker import ARGS, KWARGS, CONTAINS, MockerTestCase +from lava_tool.interface import LavaCommandError +from lava_tool.commands.auth import auth_add + + +class FakeArgs: + token_file = None + no_check = False + +class AuthAddTests(MockerTestCase): + + def setUp(self): + MockerTestCase.setUp(self) + self.saved_stdout = sys.stdout + sys.stdout = StringIO.StringIO() + self.saved_stderr = sys.stderr + sys.stderr = StringIO.StringIO() + + def tearDown(self): + MockerTestCase.tearDown(self) + sys.stdout = self.saved_stdout + sys.stderr = self.saved_stderr + + def make_command(self, auth_backend, **kwargs): + args = FakeArgs() + args.__dict__.update(kwargs) + return auth_add(None, args, auth_backend) + + def test_token_taken_from_argument(self): + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com', no_check=True) + cmd.invoke() + self.assertEqual( + 'TOKEN', auth_backend.get_token_for_host('user', 'example.com')) + + def test_token_taken_from_getpass(self): + mocked_getpass = self.mocker.replace('getpass.getpass', passthrough=False) + mocked_getpass(CONTAINS('Paste token')) + self.mocker.result("TOKEN") + self.mocker.replay() + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user@example.com', no_check=True) + cmd.invoke() + self.assertEqual( + 'TOKEN', auth_backend.get_token_for_host('user', 'example.com')) + + def test_token_taken_from_file(self): + auth_backend = MemoryAuthBackend([]) + token_file = tempfile.NamedTemporaryFile('w') + token_file.write("TOKEN") + token_file.flush() + cmd = self.make_command( + auth_backend, HOST='http://user@example.com', no_check=True, + token_file=token_file.name) + cmd.invoke() + self.assertEqual( + 'TOKEN', auth_backend.get_token_for_host('user', 'example.com')) + + def test_token_file_and_in_url_conflict(self): + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com', no_check=True, + token_file='some-file-name') + self.assertRaises(LavaCommandError, cmd.invoke) + + def test_non_existent_token_reported(self): + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com', no_check=True, + token_file='does-not-exist') + self.assertRaises(LavaCommandError, cmd.invoke) + + def test_user_taken_from_getuser(self): + mocked_getuser = self.mocker.replace('getpass.getuser', passthrough=False) + mocked_getuser() + self.mocker.result("user") + self.mocker.replay() + auth_backend = MemoryAuthBackend([]) + token_file = tempfile.NamedTemporaryFile('w') + token_file.write("TOKEN") + token_file.flush() + cmd = self.make_command( + auth_backend, HOST='http://example.com', no_check=True, + token_file=token_file.name) + cmd.invoke() + self.assertEqual( + 'TOKEN', auth_backend.get_token_for_host('user', 'example.com')) + + def test_port_included(self): + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com:1234', no_check=True) + cmd.invoke() + self.assertEqual( + 'TOKEN', auth_backend.get_token_for_host('user', 'example.com:1234')) + + def test_check_made(self): + mocked_AuthenticatingServerProxy = self.mocker.replace( + 'lava_tool.authtoken.AuthenticatingServerProxy', passthrough=False) + mocked_sp = mocked_AuthenticatingServerProxy(ARGS, KWARGS) + # nospec() is required because of + # https://bugs.launchpad.net/mocker/+bug/794351 + self.mocker.nospec() + mocked_sp.system.whoami() + self.mocker.result('user') + self.mocker.replay() + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com:1234', no_check=False) + cmd.invoke() + self.assertEqual( + 'TOKEN', auth_backend.get_token_for_host('user', 'example.com:1234')) + + def test_check_auth_failure_reported_nicely(self): + mocked_AuthenticatingServerProxy = self.mocker.replace( + 'lava_tool.authtoken.AuthenticatingServerProxy', passthrough=False) + mocked_sp = mocked_AuthenticatingServerProxy(ARGS, KWARGS) + # nospec() is required because of + # https://bugs.launchpad.net/mocker/+bug/794351 + self.mocker.nospec() + mocked_sp.system.whoami() + self.mocker.throw(xmlrpclib.ProtocolError('', 401, '', [])) + self.mocker.replay() + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com', no_check=False) + self.assertRaises(LavaCommandError, cmd.invoke) + + def test_check_fails_token_not_recorded(self): + mocked_AuthenticatingServerProxy = self.mocker.replace( + 'lava_tool.authtoken.AuthenticatingServerProxy', passthrough=False) + mocked_sp = mocked_AuthenticatingServerProxy(ARGS, KWARGS) + self.mocker.nospec() + mocked_sp.system.whoami() + self.mocker.throw(xmlrpclib.ProtocolError('', 401, '', [])) + self.mocker.replay() + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com', no_check=False) + self.assertRaises(LavaCommandError, cmd.invoke) + self.assertEqual( + None, auth_backend.get_token_for_host('user', 'example.com')) + + def test_check_other_http_failure_just_raised(self): + mocked_AuthenticatingServerProxy = self.mocker.replace( + 'lava_tool.authtoken.AuthenticatingServerProxy', passthrough=False) + mocked_sp = mocked_AuthenticatingServerProxy(ARGS, KWARGS) + # nospec() is required because of + # https://bugs.launchpad.net/mocker/+bug/794351 + self.mocker.nospec() + mocked_sp.system.whoami() + self.mocker.throw(xmlrpclib.ProtocolError('', 500, '', [])) + self.mocker.replay() + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com', no_check=False) + self.assertRaises(xmlrpclib.ProtocolError, cmd.invoke) + + def test_fault_reported(self): + mocked_AuthenticatingServerProxy = self.mocker.replace( + 'lava_tool.authtoken.AuthenticatingServerProxy', passthrough=False) + mocked_sp = mocked_AuthenticatingServerProxy(ARGS, KWARGS) + # nospec() is required because of + # https://bugs.launchpad.net/mocker/+bug/794351 + self.mocker.nospec() + mocked_sp.system.whoami() + self.mocker.throw(xmlrpclib.Fault(100, 'faultString')) + self.mocker.replay() + auth_backend = MemoryAuthBackend([]) + cmd = self.make_command( + auth_backend, HOST='http://user:TOKEN@example.com', no_check=False) + self.assertRaises(LavaCommandError, cmd.invoke) === added file 'lava_tool/tests/test_authtoken.py' --- lava_tool/tests/test_authtoken.py 1970-01-01 00:00:00 +0000 +++ lava_tool/tests/test_authtoken.py 2011-06-09 05:30:32 +0000 @@ -0,0 +1,69 @@ +# Copyright (C) 2011 Linaro Limited +# +# Author: Michael Hudson-Doyle +# +# This file is part of lava-tool. +# +# lava-tool is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation +# +# lava-tool is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with lava-tool. If not, see . + +""" +Unit tests for the lava_tool.authtoken package +""" + +import base64 +from unittest import TestCase + +from lava_tool.authtoken import ( + AuthenticatingTransportMixin, + MemoryAuthBackend, + ) +from lava_tool.interface import LavaCommandError + + +class TestAuthenticatingTransportMixin(TestCase): + + def headers_for_host(self, host, auth_backend): + a = AuthenticatingTransportMixin() + a.auth_backend = auth_backend + _, headers, _ = a.get_host_info(host) + return headers + + def user_and_password_from_headers(self, headers): + if len(headers) != 1: + self.fail("expected exactly 1 header, got %r" % headers) + [(name, value)] = headers + if name != 'Authorization': + self.fail("non-authorization header found in %r" % headers) + if not value.startswith("Basic "): + self.fail("non-basic auth header found in %r" % headers) + auth = base64.b64decode(value[len("Basic "):]) + if ':' in auth: + return tuple(auth.split(':', 1)) + else: + return (auth, None) + + def test_no_user_no_auth(self): + headers = self.headers_for_host('example.com', MemoryAuthBackend([])) + self.assertEqual(None, headers) + + def test_error_when_user_but_no_token(self): + self.assertRaises( + LavaCommandError, + self.headers_for_host, 'user@example.com', MemoryAuthBackend([])) + + def test_token_used_for_auth(self): + headers = self.headers_for_host( + 'user@example.com', + MemoryAuthBackend([('user', 'example.com', "TOKEN")])) + self.assertEqual( + ('user', 'TOKEN'), self.user_and_password_from_headers(headers)) === modified file 'setup.py' --- setup.py 2011-05-04 11:57:26 +0000 +++ setup.py 2011-06-08 01:06:58 +0000 @@ -45,6 +45,7 @@ lava-tool = lava_tool.dispatcher:main [lava_tool.commands] help = lava_tool.commands.misc:help + auth-add = lava_tool.commands.auth:auth_add """, classifiers=[ "Development Status :: 4 - Beta", @@ -55,7 +56,8 @@ "Topic :: Software Development :: Testing", ], install_requires = [ - 'argparse >= 1.1' + 'argparse >= 1.1', + 'keyring', ], setup_requires = [ 'versiontools >= 1.1',