Add show_account subcommand to retrieve account info from ACME server (#9127)

* Fetch and print account contacts from ACME server

* Add tests

* Add changelog entryAdd changelog entry

* Add account URI and thumbprint output

Only show these items when verbosity > 0

* Add test case for account URI and thumbprint

* Move changelog entry to new placeholder

* Add test for `cb_client.acme` (coverage)

* Address comments

* Update changelog

* Few small word changes

* Add server to error messages

* Remove phone contact parts
This commit is contained in:
osirisinferi 2021-12-27 09:12:52 +01:00 committed by GitHub
parent a391a34631
commit 93c2852fdb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 167 additions and 8 deletions

View file

@ -6,7 +6,9 @@ Certbot adheres to [Semantic Versioning](https://semver.org/).
### Added
*
* Added `show_account` subcommand, which will fetch the account information
from the ACME server and show the account details (account URL and, if
applicable, email address or addresses)
### Changed

View file

@ -43,6 +43,7 @@ manage your account:
register Create an ACME account
unregister Deactivate an ACME account
update_account Update an ACME account
show_account Display account details
--agree-tos Agree to the ACME server's Subscriber Agreement
-m EMAIL Email address for important account notifications
"""

View file

@ -56,6 +56,7 @@ class HelpfulArgumentParser:
"plugins": main.plugins_cmd,
"register": main.register,
"update_account": main.update_account,
"show_account": main.show_account,
"unregister": main.unregister,
"renew": main.renew,
"revoke": main.revoke,

View file

@ -46,5 +46,5 @@ def _paths_parser(helpful: "helpful.HelpfulArgumentParser") -> None:
help=config_help("work_dir"))
add("paths", "--logs-dir", default=flag_default("logs_dir"),
help="Logs directory.")
add("paths", "--server", default=flag_default("server"),
add(["paths", "show_account"], "--server", default=flag_default("server"),
help=config_help("server"))

View file

@ -97,6 +97,11 @@ VERB_HELP = [
"to already existing configuration."),
"usage": "\n\n certbot enhance [options]\n\n"
}),
("show_account", {
"short": "Show account details from an ACME server",
"opts": 'Options useful for the "show_account" subcommand:',
"usage": "\n\n certbot show_account [options]\n\n"
}),
]

View file

@ -811,7 +811,7 @@ def unregister(config: configuration.NamespaceConfig,
accounts = account_storage.find_all()
if not accounts:
return "Could not find existing account to deactivate."
return f"Could not find existing account for server {config.server}."
prompt = ("Are you sure you would like to irrevocably deactivate "
"your account?")
wants_deactivate = display_util.yesno(prompt, yes_label='Deactivate', no_label='Abort',
@ -846,7 +846,7 @@ def register(config: configuration.NamespaceConfig,
:param unused_plugins: List of plugins (deprecated)
:type unused_plugins: plugins_disco.PluginsRegistry
:returns: `None` or a string indicating and error
:returns: `None` or a string indicating an error
:rtype: None or str
"""
@ -877,7 +877,7 @@ def update_account(config: configuration.NamespaceConfig,
:param unused_plugins: List of plugins (deprecated)
:type unused_plugins: plugins_disco.PluginsRegistry
:returns: `None` or a string indicating and error
:returns: `None` or a string indicating an error
:rtype: None or str
"""
@ -887,7 +887,7 @@ def update_account(config: configuration.NamespaceConfig,
accounts = account_storage.find_all()
if not accounts:
return "Could not find an existing account to update."
return f"Could not find an existing account for server {config.server}."
if config.email is None and not config.register_unsafely_without_email:
config.email = display_ops.get_email(optional=False)
@ -921,6 +921,53 @@ def update_account(config: configuration.NamespaceConfig,
return None
def show_account(config: configuration.NamespaceConfig,
unused_plugins: plugins_disco.PluginsRegistry) -> Optional[str]:
"""Fetch account info from the ACME server and show it to the user.
:param config: Configuration object
:type config: configuration.NamespaceConfig
:param unused_plugins: List of plugins (deprecated)
:type unused_plugins: plugins_disco.PluginsRegistry
:returns: `None` or a string indicating an error
:rtype: None or str
"""
# Portion of _determine_account logic to see whether accounts already
# exist or not.
account_storage = account.AccountFileStorage(config)
accounts = account_storage.find_all()
if not accounts:
return f"Could not find an existing account for server {config.server}."
acc, acme = _determine_account(config)
cb_client = client.Client(config, acc, None, None, acme=acme)
if not cb_client.acme:
raise errors.Error("ACME client is not set.")
regr = cb_client.acme.query_registration(acc.regr)
output = [f"Account details for server {config.server}:",
f" Account URL: {regr.uri}"]
emails = []
for contact in regr.body.contact:
if contact.startswith('mailto:'):
emails.append(contact[7:])
output.append(" Email contact{}: {}".format(
"s" if len(emails) > 1 else "",
", ".join(emails) if len(emails) > 0 else "none"))
display_util.notify("\n".join(output))
return None
def _cert_name_from_config_or_lineage(config: configuration.NamespaceConfig,
lineage: Optional[storage.RenewableCert]) -> Optional[str]:
if lineage:

View file

@ -1595,10 +1595,11 @@ class UnregisterTest(unittest.TestCase):
self.mocks['client'].Client.return_value = cb_client
config = mock.MagicMock()
config.server = "https://acme.example.com/directory"
unused_plugins = mock.MagicMock()
res = main.unregister(config, unused_plugins)
m = "Could not find existing account to deactivate."
m = "Could not find existing account for server https://acme.example.com/directory."
self.assertEqual(res, m)
self.assertIs(cb_client.acme.deactivate_registration.called, False)
@ -2025,7 +2026,8 @@ class UpdateAccountTest(test_util.ConfigTestCase):
mock_storage.find_all.return_value = []
self.mocks['account'].AccountFileStorage.return_value = mock_storage
self.assertEqual(self._call(['update_account', '--email', 'user@example.org']),
'Could not find an existing account to update.')
'Could not find an existing account for server'
' https://acme-v02.api.letsencrypt.org/directory.')
def test_update_account_remove_email(self):
"""Test that --register-unsafely-without-email is handled as no email"""
@ -2070,5 +2072,106 @@ class UpdateAccountTest(test_util.ConfigTestCase):
'Your e-mail address was updated to user@example.com,user@example.org.')
class ShowAccountTest(test_util.ConfigTestCase):
"""Tests for certbot._internal.main.show_account"""
def setUp(self):
patches = {
'account': mock.patch('certbot._internal.main.account'),
'atexit': mock.patch('certbot.util.atexit'),
'client': mock.patch('certbot._internal.main.client'),
'determine_account': mock.patch('certbot._internal.main._determine_account'),
'notify': mock.patch('certbot._internal.main.display_util.notify'),
'util': test_util.patch_display_util()
}
self.mocks = { k: patches[k].start() for k in patches }
for patch in patches.values():
self.addCleanup(patch.stop)
return super().setUp()
def _call(self, args):
with mock.patch('certbot._internal.main.sys.stdout'), \
mock.patch('certbot._internal.main.sys.stderr'):
args = ['--config-dir', self.config.config_dir,
'--work-dir', self.config.work_dir,
'--logs-dir', self.config.logs_dir, '--text'] + args
return main.main(args[:]) # NOTE: parser can alter its args!
def _prepare_mock_account(self):
mock_storage = mock.MagicMock()
mock_account = mock.MagicMock()
mock_regr = mock.MagicMock()
mock_storage.find_all.return_value = [mock_account]
self.mocks['account'].AccountFileStorage.return_value = mock_storage
mock_account.regr.body = mock_regr.body
self.mocks['determine_account'].return_value = (mock_account, mock.MagicMock())
def _test_show_account(self, contact):
self._prepare_mock_account()
mock_client = mock.MagicMock()
mock_regr = mock.MagicMock()
mock_regr.body.contact = contact
mock_regr.uri = 'https://www.letsencrypt-demo.org/acme/reg/1'
mock_regr.body.key.thumbprint.return_value = b'foobarbaz'
mock_client.acme.query_registration.return_value = mock_regr
self.mocks['client'].Client.return_value = mock_client
args = ['show_account']
self._call(args)
self.assertEqual(mock_client.acme.query_registration.call_count, 1)
def test_no_existing_accounts(self):
"""Test that no existing account is handled correctly"""
mock_storage = mock.MagicMock()
mock_storage.find_all.return_value = []
self.mocks['account'].AccountFileStorage.return_value = mock_storage
self.assertEqual(self._call(['show_account']),
'Could not find an existing account for server'
' https://acme-v02.api.letsencrypt.org/directory.')
def test_no_existing_client(self):
"""Test that issues with the ACME client are handled correctly"""
self._prepare_mock_account()
mock_client = mock.MagicMock()
mock_client.acme = None
self.mocks['client'].Client.return_value = mock_client
try:
self._call(['show_account'])
except errors.Error as e:
self.assertEqual('ACME client is not set.', str(e))
def test_no_contacts(self):
self._test_show_account(())
self.assertEqual(self.mocks['notify'].call_count, 1)
self.mocks['notify'].assert_has_calls([
mock.call('Account details for server https://acme-v02.api.letsencr'
'ypt.org/directory:\n Account URL: https://www.letsencry'
'pt-demo.org/acme/reg/1\n Email contact: none')])
def test_single_email(self):
contact = ('mailto:foo@example.com',)
self._test_show_account(contact)
self.assertEqual(self.mocks['notify'].call_count, 1)
self.mocks['notify'].assert_has_calls([
mock.call('Account details for server https://acme-v02.api.letsencr'
'ypt.org/directory:\n Account URL: https://www.letsencry'
'pt-demo.org/acme/reg/1\n Email contact: foo@example.com')])
def test_double_email(self):
contact = ('mailto:foo@example.com', 'mailto:bar@example.com')
self._test_show_account(contact)
self.assertEqual(self.mocks['notify'].call_count, 1)
self.mocks['notify'].assert_has_calls([
mock.call('Account details for server https://acme-v02.api.letsencr'
'ypt.org/directory:\n Account URL: https://www.letsencry'
'pt-demo.org/acme/reg/1\n Email contacts: foo@example.com, bar@example.com')])
if __name__ == '__main__':
unittest.main() # pragma: no cover