From ab616a598fc9684ef47ed9b2745e2d9f56a3d0db Mon Sep 17 00:00:00 2001 From: James Kasten Date: Fri, 17 Apr 2015 03:40:22 -0700 Subject: [PATCH] account integration --- letsencrypt/client/account.py | 38 +++++++---- letsencrypt/client/auth_handler.py | 12 ++-- letsencrypt/client/client.py | 71 +++++++++++++------- letsencrypt/client/network2.py | 14 +++- letsencrypt/client/tests/account_test.py | 25 +++---- letsencrypt/client/tests/client_test.py | 46 +++++++++++++ letsencrypt/client/tests/display/ops_test.py | 6 +- letsencrypt/scripts/main.py | 12 ++-- 8 files changed, 154 insertions(+), 70 deletions(-) diff --git a/letsencrypt/client/account.py b/letsencrypt/client/account.py index ab7af3652..6046ae027 100644 --- a/letsencrypt/client/account.py +++ b/letsencrypt/client/account.py @@ -1,3 +1,4 @@ +import logging import os import re @@ -11,7 +12,6 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util -from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import util as display_util @@ -142,32 +142,43 @@ class Account(object): return cls(config, key, email, phone, regr) @classmethod - def choose_account(cls, config): - """Choose one of the available accounts.""" + def get_accounts(cls, config): + """Return all current accounts. + + :param config: Configuration + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + """ + try: + filenames = os.listdir(config.accounts_dir) + except OSError: + return [] + accounts = [] - filenames = os.listdir(config.accounts_dir) for name in filenames: # Not some directory ie. keys config_fp = os.path.join(config.accounts_dir, name) if os.path.isfile(config_fp): accounts.append(cls._from_config_fp(config, config_fp)) - if len(accounts) == 1: - return accounts[0] - elif len(accounts) > 1: - return display_ops.choose_account(accounts) - else: - return None + return accounts @classmethod def from_prompts(cls, config): - """Generate an account from prompted user input.""" + """Generate an account from prompted user input. + + :param config: Configuration + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + :returns: Account or None + :rtype: :class:`letsencrypt.client.account.Account` + + """ code, email = zope.component.getUtility(interfaces.IDisplay).input( - "Enter email address") + "Enter email address (optional)") if code == display_util.OK: email = email if email != "" else None - print config.account_keys_dir le_util.make_or_verify_dir( config.account_keys_dir, 0o700, os.geteuid()) key = crypto_util.init_save_key( @@ -181,4 +192,5 @@ class Account(object): """Scrub email address before using it.""" if re.match(cls.EMAIL_REGEX, email): return bool(not email.startswith(".") and ".." not in email) + logging.warn("Invalid email address: using default address.") return False diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 72af44526..24872b43b 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -26,8 +26,8 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes messages :type network: :class:`letsencrypt.client.network2.Network` - :ivar authkey: Authorized Keys for domains. - :type authkey: :class:`letsencrypt.client.le_util.Key` + :ivar account: Client's Account + :type account: :class:`letsencrypt.client.account.Account` :ivar dict authzr: ACME Authorization Resource dict where keys are domains. :ivar list dv_c: DV challenges in the form of @@ -36,12 +36,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes form of :class:`letsencrypt.client.achallenges.AnnotatedChallenge` """ - def __init__(self, dv_auth, cont_auth, network, authkey): + def __init__(self, dv_auth, cont_auth, network, account): self.dv_auth = dv_auth self.cont_auth = cont_auth self.network = network - self.authkey = authkey + self.account = account self.authzr = dict() # List must be used to keep responses straight. @@ -275,11 +275,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes if isinstance(chall, challenges.DVSNI): logging.info(" DVSNI challenge for %s.", domain) achall = achallenges.DVSNI( - challb=challb, domain=domain, key=self.authkey) + challb=challb, domain=domain, key=self.account.key) elif isinstance(chall, challenges.SimpleHTTPS): logging.info(" SimpleHTTPS challenge for %s.", domain) achall = achallenges.SimpleHTTPS( - challb=challb, domain=domain, key=self.authkey) + challb=challb, domain=domain, key=self.account.key) elif isinstance(chall, challenges.DNS): logging.info(" DNS challenge for %s.", domain) achall = achallenges.DNS(challb=challb, domain=domain) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a89397046..92914631c 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,7 +1,6 @@ """ACME protocol client class and helper functions.""" import logging import os -import sys import M2Crypto import zope.component @@ -9,6 +8,7 @@ import zope.component from letsencrypt.acme import jose from letsencrypt.acme.jose import jwk +from letsencrypt.client import account from letsencrypt.client import auth_handler from letsencrypt.client import continuity_auth from letsencrypt.client import crypto_util @@ -30,11 +30,8 @@ class Client(object): :ivar network: Network object for sending and receiving messages :type network: :class:`letsencrypt.client.network2.Network` - :ivar authkey: Authorization Key - :type authkey: :class:`letsencrypt.client.le_util.Key` - :ivar account: Account object used for registration - :type account: :class:`letsencrypt.client.registration.Registration` + :type account: :class:`letsencrypt.client.account.Account` :ivar auth_handler: Object that supports the IAuthenticator interface. auth_handler contains both a dv_authenticator and a @@ -49,7 +46,7 @@ class Client(object): """ - def __init__(self, config, authkey, dv_auth, installer): + def __init__(self, config, account, dv_auth, installer): """Initialize a client. :param dv_auth: IAuthenticator that can solve the @@ -59,37 +56,39 @@ class Client(object): :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - self.authkey = authkey - self.account = None + self.account = account + self.installer = installer # TODO: Allow for other alg types besides RS256 self.network = network2.Network( "https://%s/acme/new-reg" % config.server, - jwk.JWKRSA.load(authkey.pem)) + jwk.JWKRSA.load(account.key.pem)) self.config = config if dv_auth is not None: cont_auth = continuity_auth.ContinuityAuthenticator(config) self.auth_handler = auth_handler.AuthHandler( - dv_auth, cont_auth, self.network, self.authkey) + dv_auth, cont_auth, self.network, self.account) else: self.auth_handler = None - def register(self, network, store=True): - """New Registration with the ACME server. - - :param bool store: Whether to store the registration information - - """ + def register(self, save=True): + """New Registration with the ACME server.""" self.account = self.network.register_from_account(self.account) - if self.account.regr.terms_of_service or self.config.tos: - agree = zope.component.getUtility(interfaces.IDisplay).yesno( - self.account.regr.terms_of_service, "Agree", "Cancel") + if self.account.terms_of_service: + if not self.config.tos: + agree = zope.component.getUtility(interfaces.IDisplay).yesno( + self.account.terms_of_service, "Agree", "Cancel") + else: + agree = True + if agree: self.account.regr = self.network.agree_to_tos(self.account.regr) - # TODO: Handle case where user doesn't agree + # TODO: Handle case where user doesn't agree + + self.account.save() def obtain_certificate(self, domains, csr=None): """Obtains a certificate from the ACME server. @@ -111,14 +110,14 @@ class Client(object): "not set.") logging.warning(msg) raise errors.LetsEncryptClientError(msg) - if self.regr is None: + if self.account.regr is None: raise errors.LetsEncryptClientError( "Please register with the ACME server first.") # Perform Challenges/Get Authorizations - if self.regr.new_authzr_uri: + if self.account.new_authzr_uri: authzr = self.auth_handler.get_authorizations( - domains, self.regr.new_authzr_uri) + domains, self.account.new_authzr_uri) # This isn't required to be in the registration resource... # and it isn't standardized... ugh - acme-spec #93 else: @@ -129,7 +128,7 @@ class Client(object): # Create CSR from names if csr is None: csr = crypto_util.init_save_csr( - self.authkey, domains, self.config.cert_dir) + self.account.key, domains, self.config.cert_dir) # Retrieve certificate certr = self.network.request_issuance( @@ -142,7 +141,7 @@ class Client(object): certr, self.config.cert_path, self.config.chain_path) revoker.Revoker.store_cert_key( - cert_file, self.authkey.file, self.config) + cert_file, self.account.key.file, self.config) return cert_file, chain_file @@ -379,6 +378,28 @@ def determine_authenticator(all_auths, config): return auth +def determine_account(config): + """Determine which account to use. + + Will create an account if necessary. + + :param config: Configuration object + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + :returns: Account + :rtype: :class:`letsencrypt.client.account.Account` + + """ + accounts = account.Account.get_accounts(config) + + if len(accounts) == 1: + return accounts[0] + elif len(accounts) > 1: + return display_ops.choose_account(accounts) + + return account.Account.from_prompts(config) + + def determine_installer(config): """Returns a valid installer if one exists. diff --git a/letsencrypt/client/network2.py b/letsencrypt/client/network2.py index 011710dbe..e740b4240 100644 --- a/letsencrypt/client/network2.py +++ b/letsencrypt/client/network2.py @@ -187,10 +187,18 @@ class Network(object): return regr def register_from_account(self, account): - # TODO: properly format/scrub phone number and email + """Register with server. + + :param account: Account + :type account: :class:`letsencrypt.client.account.Account` + + :returns: Updated account + :rtype: :class:`letsencrypt.client.account.Account` + + """ details = ( - "mailto:" + self.email if self.email is not None else None, - "tel:" + self.phone if self.phone is not None else None + "mailto:" + account.email if account.email is not None else None, + "tel:" + account.phone if account.phone is not None else None ) contact_tuple = tuple(det for det in details if det is not None) diff --git a/letsencrypt/client/tests/account_test.py b/letsencrypt/client/tests/account_test.py index 18f87270a..5baafe7d8 100644 --- a/letsencrypt/client/tests/account_test.py +++ b/letsencrypt/client/tests/account_test.py @@ -93,7 +93,6 @@ class AccountTest(unittest.TestCase): self.assertTrue(partial.terms_of_service is None) self.assertTrue(partial.recovery_token is None) - def test_partial_account_default(self): partial = account.Account(self.config, self.key) partial.save() @@ -105,25 +104,19 @@ class AccountTest(unittest.TestCase): self.assertEqual(partial.phone, acc.phone) self.assertEqual(partial.regr, acc.regr) - @mock.patch("letsencrypt.client.account.display_ops.choose_account") - def test_choose_account(self, mock_op): - mock_op.return_value = self.test_account + def test_get_accounts(self): + accs = account.Account.get_accounts(self.config) + self.assertFalse(accs) - # Test 0 - self.assertTrue(account.Account.choose_account(self.config) is None) - - # Test 1 self.test_account.save() - acc = account.Account.choose_account(self.config) - self.assertEqual(acc.email, self.test_account.email) + accs = account.Account.get_accounts(self.config) + self.assertEqual(len(accs), 1) + self.assertEqual(accs[0].email, self.test_account.email) - # Test multiple - self.assertFalse(mock_op.called) - acc2 = account.Account(self.config, self.key) + acc2 = account.Account(self.config, self.key, "testing_email@gmail.com") acc2.save() - test_acc = account.Account.choose_account(self.config) - self.assertTrue(mock_op.called) - self.assertTrue(test_acc.email, self.test_account.email) + accs = account.Account.get_accounts(self.config) + self.assertEqual(len(accs), 2) def _read_out_config(self, filep): print open(os.path.join(self.accounts_dir, filep)).read() diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 63170b517..696a83f93 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -1,10 +1,56 @@ """letsencrypt.client.client.py tests.""" +import os import unittest +import shutil +import tempfile import mock +from letsencrypt.client import account from letsencrypt.client import configuration from letsencrypt.client import errors +from letsencrypt.client import le_util + + +class DetermineAccountTest(unittest.TestCase): + def setUp(self): + self.accounts_dir = tempfile.mkdtemp("accounts") + self.account_keys_dir = os.path.join(self.accounts_dir, "keys") + os.makedirs(self.account_keys_dir, 0o700) + + self.config = mock.MagicMock( + spec=configuration.NamespaceConfig, accounts_dir=self.accounts_dir, + account_keys_dir=self.account_keys_dir, rsa_key_size=2048, + server="letsencrypt-demo.org") + + def tearDown(self): + shutil.rmtree(self.accounts_dir) + + @mock.patch("letsencrypt.client.client.account.Account.from_prompts") + @mock.patch("letsencrypt.client.client.display_ops.choose_account") + def determine_account(self, mock_op, mock_prompt): + from letsencrypt.client import client + + key = le_util.Key("file", "pem") + test_acc = account.Account(self.config, key, "email1@gmail.com") + mock_op.return_value = test_acc + + # Test 0 + mock_prompt.return_value = None + self.assertTrue(client.determine_account(self.config) is None) + + # Test 1 + test_acc.save() + acc = client.determine_account(self.config) + self.assertEqual(acc.email, test_acc.email) + + # Test multiple + self.assertFalse(mock_op.called) + acc2 = account.Account(self.config, self.key) + acc2.save() + chosen_acc = client.determine_account(self.config) + self.assertTrue(mock_op.called) + self.assertTrue(chosen_acc.email, test_acc.email) class DetermineAuthenticatorTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 8c3f59939..73b6ba430 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -72,8 +72,9 @@ class ChooseAccountTest(unittest.TestCase): server="letsencrypt-demo.org") self.key = le_util.Key("keypath", "pem") - self.acc1 = account.Account(self.config, self.key, "email1") - self.acc2 = account.Account(self.config, self.key, "email2", "phone") + self.acc1 = account.Account(self.config, self.key, "email1@g.com") + self.acc2 = account.Account( + self.config, self.key, "email2@g.com", "phone") self.acc1.save() self.acc2.save() @@ -84,6 +85,7 @@ class ChooseAccountTest(unittest.TestCase): @mock.patch("letsencrypt.client.display.ops.util") def test_one(self, mock_util): + print self.acc1 mock_util().menu.return_value = (display_util.OK, 0) self.assertEqual(self._call([self.acc1]), self.acc1) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 225154cba..3e31480af 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -196,14 +196,16 @@ def main(): # pylint: disable=too-many-branches, too-many-statements # Prepare for init of Client if args.authkey is None: - authkey = crypto_util.init_save_key(args.rsa_key_size, config.key_dir) + account = client.determine_account(config) else: - authkey = le_util.Key(args.authkey[0], args.authkey[1]) + # TODO: Figure out what to do with this + # le_util.Key(args.authkey[0], args.authkey[1]) + account = client.determine_account(config) - acme = client.Client(config, authkey, auth, installer) + acme = client.Client(config, account, auth, installer) # Validate the key and csr - client.validate_key_csr(authkey) + client.validate_key_csr(account.key) # This more closely mimics the capabilities of the CLI # It should be possible for reconfig only, install-only, no-install @@ -214,7 +216,7 @@ def main(): # pylint: disable=too-many-branches, too-many-statements acme.register() cert_file, chain_file = acme.obtain_certificate(doms) if installer is not None and cert_file is not None: - acme.deploy_certificate(doms, authkey, cert_file, chain_file) + acme.deploy_certificate(doms, account.key, cert_file, chain_file) if installer is not None: acme.enhance_config(doms, args.redirect)