diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 36dac9883a..4dd19eefd5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -52,6 +52,9 @@ in development * Let querier plugin decide whether to delete state object on error. Mistral querier will delete state object on workflow completion or when the workflow or task references no longer exists. (improvement) +* Added support for `st2 login` and `st2 whoami` commands. These add some additional functionality + beyond the existing `st2 auth` command and actually works with the local configuration so that + users do not have to. 2.1.1 - December 16, 2016 ------------------------- diff --git a/st2client/st2client/base.py b/st2client/st2client/base.py index b0269d7198..58c8907402 100644 --- a/st2client/st2client/base.py +++ b/st2client/st2client/base.py @@ -15,6 +15,7 @@ import os import json +import logging import time import calendar import traceback @@ -56,7 +57,7 @@ class BaseCLIApp(object): Base class for StackStorm CLI apps. """ - LOG = None # logger instance to use + LOG = logging.getLogger(__name__) # logger instance to use client = None # st2client instance # A list of command classes for which automatic authentication should be skipped. diff --git a/st2client/st2client/commands/auth.py b/st2client/st2client/commands/auth.py index a4a9a997f2..ecf37bbfe6 100644 --- a/st2client/st2client/commands/auth.py +++ b/st2client/st2client/commands/auth.py @@ -13,10 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +from configparser import ConfigParser import getpass import json import logging +from st2client.base import BaseCLIApp +from st2client import config_parser from st2client import models from st2client.commands import resource from st2client.commands.noop import NoopCommand @@ -37,7 +40,7 @@ def __init__(self, resource, *args, **kwargs): super(TokenCreateCommand, self).__init__( resource, kwargs.pop('name', 'create'), - 'Authenticate user and aquire access token.', + 'Authenticate user and acquire access token.', *args, **kwargs) self.parser.add_argument('username', @@ -70,6 +73,124 @@ def run_and_print(self, args, **kwargs): attributes=self.display_attributes, json=args.json, yaml=args.yaml) +class LoginCommand(resource.ResourceCommand): + display_attributes = ['user', 'token', 'expiry'] + + def __init__(self, resource, *args, **kwargs): + + kwargs['has_token_opt'] = False + + super(LoginCommand, self).__init__( + resource, kwargs.pop('name', 'create'), + 'Authenticate user, acquire access token, and update CLI config directory', + *args, **kwargs) + + self.parser.add_argument('username', + help='Name of the user to authenticate.') + + self.parser.add_argument('-p', '--password', dest='password', + help='Password for the user. If password is not provided, ' + 'it will be prompted.') + self.parser.add_argument('-l', '--ttl', type=int, dest='ttl', default=None, + help='The life span of the token in seconds. ' + 'Max TTL configured by the admin supersedes this.') + self.parser.add_argument('-w', '--write-password', action='store_true', default=False, + dest='write_password', + help='Write the password in plain text to the config file ' + '(default is to omit it') + + def run(self, args, **kwargs): + + if not args.password: + args.password = getpass.getpass() + instance = self.resource(ttl=args.ttl) if args.ttl else self.resource() + + cli = BaseCLIApp() + + # Determine path to config file + try: + config_file = cli._get_config_file_path(args) + except ValueError: + # config file not found in args or in env, defaulting + config_file = config_parser.ST2_CONFIG_PATH + + # Retrieve token + manager = self.manager.create(instance, auth=(args.username, args.password), **kwargs) + cli._cache_auth_token(token_obj=manager) + + # Update existing configuration with new credentials + config = ConfigParser() + config.read(config_file) + + # Modify config (and optionally populate with password) + if 'credentials' not in config: + config.add_section('credentials') + config['credentials'] = {} + config['credentials']['username'] = args.username + if args.write_password: + config['credentials']['password'] = args.password + else: + # Remove any existing password from config + config['credentials'].pop('password', None) + + with open(config_file, "w") as cfg_file_out: + config.write(cfg_file_out) + + return manager + + def run_and_print(self, args, **kwargs): + try: + self.run(args, **kwargs) + print("Logged in as %s" % (args.username)) + except Exception as e: + print("Failed to log in as %s: %s" % (args.username, str(e))) + if self.app.client.debug: + raise + + +class WhoamiCommand(resource.ResourceCommand): + display_attributes = ['user', 'token', 'expiry'] + + def __init__(self, resource, *args, **kwargs): + + kwargs['has_token_opt'] = False + + super(WhoamiCommand, self).__init__( + resource, kwargs.pop('name', 'create'), + 'Display the currently authenticated/configured user', + *args, **kwargs) + + def run(self, args, **kwargs): + + cli = BaseCLIApp() + + # Determine path to config file + try: + config_file = cli._get_config_file_path(args) + except ValueError: + # config file not found in args or in env, defaulting + config_file = config_parser.ST2_CONFIG_PATH + + # Update existing configuration with new credentials + config = ConfigParser() + config.read(config_file) + + return config['credentials']['username'] + + def run_and_print(self, args, **kwargs): + try: + username = self.run(args, **kwargs) + print("Currently logged in as %s" % username) + except KeyError: + print("No user is currently logged in") + if self.app.client.debug: + raise + except Exception: + print("Unable to retrieve currently logged-in user") + if self.app.client.debug: + raise + + class ApiKeyBranch(resource.ResourceBranch): def __init__(self, description, app, subparsers, parent_parser=None): diff --git a/st2client/st2client/shell.py b/st2client/st2client/shell.py index 50d3713807..c26c302984 100755 --- a/st2client/st2client/shell.py +++ b/st2client/st2client/shell.py @@ -50,7 +50,7 @@ from st2client.config import set_config from st2client.exceptions.operations import OperationFailureException from st2client.utils.logging import LogLevelFilter, set_log_level_for_all_loggers -from st2client.commands.auth import TokenCreateCommand +from st2client.commands.auth import TokenCreateCommand, LoginCommand __all__ = [ 'Shell' @@ -65,7 +65,8 @@ class Shell(BaseCLIApp): LOG = LOGGER SKIP_AUTH_CLASSES = [ - TokenCreateCommand.__name__ + TokenCreateCommand.__name__, + LoginCommand.__name__ ] def __init__(self): @@ -188,6 +189,9 @@ def __init__(self): 'for reuse in sensors, actions, and rules.', self, self.subparsers) + self.commands['login'] = auth.LoginCommand( + models.Token, self, self.subparsers, name='login') + self.commands['pack'] = pack.PackBranch( 'A group of related integration resources: ' 'actions, rules, and sensors.', @@ -235,6 +239,9 @@ def __init__(self): 'Webhooks.', self, self.subparsers) + self.commands['whoami'] = auth.WhoamiCommand( + models.Token, self, self.subparsers, name='whoami') + self.commands['timer'] = timer.TimerBranch( 'Timers.', self, self.subparsers) diff --git a/st2client/tests/unit/test_auth.py b/st2client/tests/unit/test_auth.py index 5cab3361a9..a35cede30a 100644 --- a/st2client/tests/unit/test_auth.py +++ b/st2client/tests/unit/test_auth.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from mock import call import os import uuid import json @@ -23,6 +24,7 @@ import logging from tests import base +from st2client import config_parser from st2client import shell from st2client.models.core import add_auth_token_to_kwargs_from_env from st2client.commands.resource import add_auth_token_to_kwargs_from_cli @@ -39,6 +41,279 @@ } +class TestWhoami(base.BaseCLITestCase): + + def __init__(self, *args, **kwargs): + super(TestWhoami, self).__init__(*args, **kwargs) + self.parser = argparse.ArgumentParser() + self.parser.add_argument('-t', '--token', dest='token') + self.parser.add_argument('--api-key', dest='api_key') + self.shell = shell.Shell() + + @mock.patch.object( + requests, 'post', + mock.MagicMock(return_value=base.FakeResponse(json.dumps({}), 200, 'OK'))) + @mock.patch("st2client.commands.auth.ConfigParser") + @mock.patch("st2client.commands.auth.open") + @mock.patch("st2client.commands.auth.BaseCLIApp") + @mock.patch("st2client.commands.auth.getpass") + def test_whoami(self, mock_gp, mock_cli, mock_open, mock_cfg): + """Test 'st2 whoami' functionality + """ + + # Mock config + config_file = config_parser.ST2_CONFIG_PATH + self.shell._get_config_file_path = mock.MagicMock(return_value="/tmp/st2config") + mock_cli.return_value._get_config_file_path.return_value = config_file + + self.shell.run(['whoami']) + + mock_cfg.return_value.__getitem__.assert_called_with('credentials') + mock_cfg.return_value.__getitem__('credentials').__getitem__.assert_called_with('username') + + @mock.patch.object( + requests, 'post', + mock.MagicMock(return_value=base.FakeResponse(json.dumps({}), 200, 'OK'))) + @mock.patch("st2client.commands.auth.ConfigParser") + @mock.patch("st2client.commands.auth.open") + @mock.patch("st2client.commands.auth.BaseCLIApp") + @mock.patch("st2client.commands.auth.getpass") + def test_whoami_not_logged_in(self, mock_gp, mock_cli, mock_open, mock_cfg): + """Test 'st2 whoami' functionality with a missing username + """ + + # Mock config + config_file = config_parser.ST2_CONFIG_PATH + self.shell._get_config_file_path = mock.MagicMock(return_value="/tmp/st2config") + mock_cli.return_value._get_config_file_path.return_value = config_file + + # Trigger keyerror exception when trying to access username. + # We have to do it this way because ConfigParser acts like a + # dict but also has methods like read() + attrs = {'__getitem__.side_effect': KeyError} + mock_cfg.return_value.__getitem__.return_value.configure_mock(**attrs) + + # assert that the config field lookup caused the CLI to return an error code + # we are also using "--debug" flag to ensure the exception is re-raised once caught + self.assertEqual( + self.shell.run(['--debug', 'whoami']), + 1 + ) + + # Some additional asserts to ensure things are being called correctly + mock_cfg.return_value.__getitem__.assert_called_with('credentials') + mock_cfg.return_value.__getitem__.return_value.__getitem__.assert_called_with('username') + + @mock.patch.object( + requests, 'post', + mock.MagicMock(return_value=base.FakeResponse(json.dumps({}), 200, 'OK'))) + @mock.patch("st2client.commands.auth.ConfigParser") + @mock.patch("st2client.commands.auth.open") + @mock.patch("st2client.commands.auth.BaseCLIApp") + @mock.patch("st2client.commands.auth.getpass") + def test_whoami_missing_credentials_section(self, mock_gp, mock_cli, mock_open, mock_cfg): + """Test 'st2 whoami' functionality with a missing credentials section + """ + + # mocked config that is empty (no credentials section at all) + mock_cfg.return_value.read.return_value = {} + + # Mock config + config_file = config_parser.ST2_CONFIG_PATH + self.shell._get_config_file_path = mock.MagicMock(return_value="/tmp/st2config") + mock_cli.return_value._get_config_file_path.return_value = config_file + + # Trigger keyerror exception when trying to access username. + # We have to do it this way because ConfigParser acts like a + # dict but also has methods like read() + attrs = {'__getitem__.side_effect': KeyError} + mock_cfg.return_value.configure_mock(**attrs) + + # assert that the config field lookup caused the CLI to return an error code + # we are also using "--debug" flag to ensure the exception is re-raised once caught + self.assertEqual( + self.shell.run(['--debug', 'whoami']), + 1 + ) + + # An additional assert to ensure things are being called correctly + mock_cfg.return_value.__getitem__.assert_called_with('credentials') + + +class TestLogin(base.BaseCLITestCase): + + def __init__(self, *args, **kwargs): + super(TestLogin, self).__init__(*args, **kwargs) + self.parser = argparse.ArgumentParser() + self.parser.add_argument('-t', '--token', dest='token') + self.parser.add_argument('--api-key', dest='api_key') + self.shell = shell.Shell() + + @mock.patch.object( + requests, 'post', + mock.MagicMock(return_value=base.FakeResponse(json.dumps({}), 200, 'OK'))) + @mock.patch("st2client.commands.auth.ConfigParser") + @mock.patch("st2client.commands.auth.open") + @mock.patch("st2client.commands.auth.BaseCLIApp") + @mock.patch("st2client.commands.auth.getpass") + def test_login_password_and_config(self, mock_gp, mock_cli, mock_open, mock_cfg): + """Test the 'st2 login' functionality by providing a password, and specifying a configuration file + """ + + args = ['--config', '/tmp/st2config', 'login', 'st2admin', '--password', 'Password1!'] + expected_username = 'st2admin' + + mock_gp.getpass.return_value = "Password1!" + + # Mock config + config_file = args[args.index('--config') + 1] + self.shell._get_config_file_path = mock.MagicMock(return_value="/tmp/st2config") + mock_cli.return_value._get_config_file_path.return_value = config_file + + self.shell.run(args) + + # Ensure getpass was only used if "--password" option was omitted + mock_gp.getpass.assert_not_called() + + # Ensure token was generated + mock_cli.return_value._cache_auth_token.assert_called_once() + + # Build list of expected calls for mock_cfg + config_calls = [call('username', expected_username)] + + # Ensure that the password field was removed from the config + mock_cfg.return_value.__getitem__.return_value.pop.assert_called_once_with('password', None) + + # Run common assertions for testing login functionality + self._login_common_assertions(mock_gp, mock_cli, mock_open, mock_cfg, + config_calls, config_file) + + @mock.patch.object( + requests, 'post', + mock.MagicMock(return_value=base.FakeResponse(json.dumps({}), 200, 'OK'))) + @mock.patch("st2client.commands.auth.ConfigParser") + @mock.patch("st2client.commands.auth.open") + @mock.patch("st2client.commands.auth.BaseCLIApp") + @mock.patch("st2client.commands.auth.getpass") + def test_login_no_password(self, mock_gp, mock_cli, mock_open, mock_cfg): + """Test the 'st2 login' functionality without the "--password" arg + """ + + args = ['login', 'st2admin'] + expected_username = 'st2admin' + + mock_gp.getpass.return_value = "Password1!" + + config_file = config_parser.ST2_CONFIG_PATH + mock_cli.return_value._get_config_file_path.return_value = config_file + + self.shell.run(args) + + # Ensure getpass was only used if "--password" option was omitted + mock_gp.getpass.assert_called_once() + + # Ensure token was generated + mock_cli.return_value._cache_auth_token.assert_called_once() + + config_calls = [call('username', expected_username)] + + # Ensure that the password field was removed from the config + mock_cfg.return_value.__getitem__.return_value.pop.assert_called_once_with('password', None) + + # Run common assertions for testing login functionality + self._login_common_assertions(mock_gp, mock_cli, mock_open, mock_cfg, + config_calls, config_file) + + @mock.patch.object( + requests, 'post', + mock.MagicMock(return_value=base.FakeResponse(json.dumps({}), 200, 'OK'))) + @mock.patch("st2client.commands.auth.ConfigParser") + @mock.patch("st2client.commands.auth.open") + @mock.patch("st2client.commands.auth.BaseCLIApp") + @mock.patch("st2client.commands.auth.getpass") + def test_login_password_with_write(self, mock_gp, mock_cli, mock_open, mock_cfg): + """Test the 'st2 login' functionality by providing a password, and writing it to config file + """ + + args = ['login', 'st2admin', '--password', 'Password1!', '-w'] + expected_username = 'st2admin' + + mock_gp.getpass.return_value = "Password1!" + + config_file = config_parser.ST2_CONFIG_PATH + mock_cli.return_value._get_config_file_path.return_value = config_file + + self.shell.run(args) + + # Ensure getpass was only used if "--password" option was omitted + mock_gp.getpass.assert_not_called() + + # Ensure token was generated + mock_cli.return_value._cache_auth_token.assert_called_once() + + # Build list of expected calls for mock_cfg + config_calls = [call('username', expected_username), call('password', 'Password1!')] + + # Run common assertions for testing login functionality + self._login_common_assertions(mock_gp, mock_cli, mock_open, mock_cfg, + config_calls, config_file) + + @mock.patch.object( + requests, 'post', + mock.MagicMock(return_value=base.FakeResponse(json.dumps({}), 200, 'OK'))) + @mock.patch("st2client.commands.auth.ConfigParser") + @mock.patch("st2client.commands.auth.open") + @mock.patch("st2client.commands.auth.BaseCLIApp") + @mock.patch("st2client.commands.auth.getpass") + def test_login_no_password_with_write_and_config(self, mock_gp, mock_cli, mock_open, mock_cfg): + """Test the 'st2 login' functionality by providing config file and writing password to it + """ + + args = ['--config', '/tmp/st2config', 'login', 'st2admin', '-w'] + expected_username = 'st2admin' + + mock_gp.getpass.return_value = "Password1!" + + # Mock config + config_file = args[args.index('--config') + 1] + self.shell._get_config_file_path = mock.MagicMock(return_value="/tmp/st2config") + mock_cli.return_value._get_config_file_path.return_value = config_file + + self.shell.run(args) + + mock_gp.getpass.assert_called_once() + + # Ensure token was generated + mock_cli.return_value._cache_auth_token.assert_called_once() + + # Build list of expected calls for mock_cfg + config_calls = [call('username', expected_username), call('password', 'Password1!')] + + # Run common assertions for testing login functionality + self._login_common_assertions(mock_gp, mock_cli, mock_open, mock_cfg, + config_calls, config_file) + + def _login_common_assertions(self, mock_gp, mock_cli, mock_open, mock_cfg, + config_calls, config_file): + # Ensure file was written to with a context manager + mock_open.return_value.__enter__.assert_called_once() + mock_open.return_value.__exit__.assert_called_once() + + # Make sure 'credentials' key in config was initialized properly + mock_cfg.return_value.__setitem__.assert_has_calls( + [call('credentials', {})], any_order=True + ) + + # Ensure configuration was performed properly + mock_open.assert_called_once_with(config_file, 'w') + mock_cfg.return_value.read.assert_called_once_with(config_file) + mock_cfg.return_value.add_section.assert_called_once_with('credentials') + mock_cfg.return_value.__getitem__.return_value.__setitem__.assert_has_calls( + config_calls, + any_order=True + ) + + class TestAuthToken(base.BaseCLITestCase): def __init__(self, *args, **kwargs):