Repository: StackStorm/st2

Issue:
Update CLI authentication to store login
We use LDAP for authentication. I've been sharing this one-liner with all of our internal users:

```
export ST2_AUTH_TOKEN=$(st2 auth `whoami` -t)
```

As a new user this is definitely non-intuitive. It'd be really nice to support a mechanism like

```
st2 login
```

While you could require your username, you could also just default to the current user name since thats how many (most?) corporate environments would be setup with ldap anyway.

Or you can prompt with default like:

```
$ st2 login
Username [default <user>]: <prompt for input>
Password: <prompt for input>
```

Or skip straight to username and optional password

```
$ st2 login `whoami` -p $PASSWORD
```

Lots of ways to skin the cat. But the business objective here is to make it easier for new/occasional end-users to authenticate.


Discussion:
@codyaray It seems like much of what you suggested for `st2 login` is already supported by the existing `st2 auth` option - the only thing that's missing is that `st2 auth` doesn't export to `ST2_AUTH_TOKEN` by default.

If `st2 auth` had an option to auto-export this, would that solve this problem in your mind?
Yep. Just didn't know if you wanted to piggyback or not. Either way works
for me. :)

-Cody

On Dec 13, 2016 7:43 PM, "Matt Oswalt" <notifications@github.com> wrote:

> @codyaray <https://github.com/codyaray> It seems like much of what you
> suggested for st2 login is already supported by the existing st2 auth
> option - the only thing that's missing is that st2 auth doesn't export to
> ST2_AUTH_TOKEN by default.
>
> If st2 auth had an option to auto-export this, would that solve this
> problem in your mind?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/StackStorm/st2/issues/3110#issuecomment-266918014>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AACsHuJXzWDbPsul88lAe0M2ormSKvW0ks5rH0mwgaJpZM4LMC-D>
> .
>

@codyaray The difficulty is that `st2client` won't be able to modify the environment of the parent shell due to the way environments work in Linux. This is why many tools that use this approach recommend you set the env yourself first.

We may be able to do something a little different to accomplish the same goal, however. Stay tuned here, I'll let you know if I come up with anything
My thought would be writing the token to disk with a TTL using
filecachetools or something

On Dec 14, 2016 11:46 AM, "Matt Oswalt" <notifications@github.com> wrote:

> @codyaray <https://github.com/codyaray> The difficulty is that st2client
> won't be able to modify the environment of the parent shell due to the way
> environments work in Linux. This is why many tools that use this approach
> recommend you set the env yourself first.
>
> We may be able to do something a little different to accomplish the same
> goal, however. Stay tuned here, I'll let you know if I come up with anything
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/StackStorm/st2/issues/3110#issuecomment-267103031>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AACsHtF8rPFRkLgRCK514H8yBFqo4qzJks5rICuPgaJpZM4LMC-D>
> .
>


Resolving patch:
diff --git a/st2client/st2client/base.py b/st2client/st2client/base.py
--- 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
--- 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
--- 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)


Test patch:
diff --git a/st2client/tests/unit/test_auth.py b/st2client/tests/unit/test_auth.py
--- 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):