Skip to content

Commit 95195df

Browse files
authored
Feature/2fa (#277)
* impl * tests * Changelog and user guides * style * rm unneeded error handling * unused import * rm comma * default totp=None on validate_connection * rm comma * use example.com
1 parent b7bc9bb commit 95195df

File tree

8 files changed

+129
-10
lines changed

8 files changed

+129
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ how a consumer would use the library (e.g. adding unit tests, updating documenta
2121

2222
### Added
2323

24+
- Support for users that require multi-factor authentication.
25+
2426
- New command `code42 alerts show` that displays information about a single alert.
2527

2628
- New command `code42 alerts update` that can update an alert's state or note.

docs/userguides/gettingstarted.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,16 @@ python3 -m pip install code42cli --upgrade
7272
.. important:: The Code42 CLI currently only supports token-based authentication.
7373
```
7474

75-
Create a user in Code42 to authenticate (basic authentication) and access data via the CLI. The CLI returns data based on the roles assigned to this user. To ensure that the user's rights are not too permissive, create a user with the lowest level of privilege necessary. See our [Role assignment use cases](https://support.code42.com/Administrator/Cloud/Monitoring_and_managing/Role_assignment_use_cases) for information on recommended roles. We recommend you test to confirm that the user can access the right data.
75+
Create a user in Code42 to authenticate (basic authentication) and access data via the CLI. The CLI returns data based
76+
on the roles assigned to this user. To ensure that the user's rights are not too permissive, create a user with the lowest
77+
level of privilege necessary. See our [Role assignment use cases](https://support.code42.com/Administrator/Cloud/Monitoring_and_managing/Role_assignment_use_cases)
78+
for information on recommended roles. We recommend you test to confirm that the user can access the right data.
7679

7780
If you choose not to store your password in the CLI, you must enter it for each command that requires a connection.
7881

82+
The Code42 CLI supports local accounts with MFA (multi-factor authentication) enabled. The Time-based One-Time
83+
Password (TOTP) must be provided at every invocation of the CLI, either via the `--totp` option or when prompted.
84+
7985
The Code42 CLI currently does **not** support SSO login providers or any other identity providers such as Active
8086
Directory or Okta.
8187

docs/userguides/profile.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,9 @@ To see all your profiles, do:
3333
```bash
3434
code42 profile list
3535
```
36+
37+
## Profiles with Multi-Factor Authentication
38+
39+
If your Code42 user account requires multi-factor authentication, the token is not required to create your profile but
40+
will be required for any subsequent CLI commands. The MFA token can either be passed in with the `--totp` option, or if
41+
not passed you will be prompted to enter it before the command executes.

src/code42cli/cmds/profile.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import click
44
from click import echo
55
from click import secho
6+
from py42.exceptions import Py42MFARequiredError
67

78
import code42cli.profile as cliprofile
89
from code42cli.errors import Code42CLIError
@@ -193,6 +194,10 @@ def _set_pw(profile_name, password):
193194
c42profile = cliprofile.get_profile(profile_name)
194195
try:
195196
validate_connection(c42profile.authority_url, c42profile.username, password)
197+
except Py42MFARequiredError:
198+
echo(
199+
"Multi-factor account detected. `--totp <token>` option will be required for all code42 invocations."
200+
)
196201
except Exception:
197202
secho("Password not stored!", bold=True)
198203
raise

src/code42cli/options.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def __init__(self):
4141
self._profile = get_profile()
4242
except Code42CLIError:
4343
self._profile = None
44+
self.totp = None
4445
self.debug = False
4546
self._sdk = None
4647
self.search_filters = []
@@ -59,7 +60,7 @@ def profile(self, value):
5960
@property
6061
def sdk(self):
6162
if self._sdk is None:
62-
self._sdk = create_sdk(self.profile, self.debug)
63+
self._sdk = create_sdk(self.profile, self.debug, self.totp)
6364
return self._sdk
6465

6566
def set_assume_yes(self, param):
@@ -81,6 +82,12 @@ def set_debug(ctx, param, value):
8182
ctx.ensure_object(CLIState).debug = value
8283

8384

85+
def set_totp(ctx, param, value):
86+
"""Sets TOTP token on global state object for multi-factor authentication."""
87+
if value:
88+
ctx.ensure_object(CLIState).totp = value
89+
90+
8491
def profile_option(hidden=False):
8592
opt = click.option(
8693
"--profile",
@@ -105,12 +112,24 @@ def debug_option(hidden=False):
105112
return opt
106113

107114

115+
def totp_option(hidden=False):
116+
opt = click.option(
117+
"--totp",
118+
expose_value=False,
119+
callback=set_totp,
120+
hidden=hidden,
121+
help="TOTP token for multi-factor authentication.",
122+
)
123+
return opt
124+
125+
108126
pass_state = click.make_pass_decorator(CLIState, ensure=True)
109127

110128

111129
def sdk_options(hidden=False):
112130
def decorator(f):
113131
f = profile_option(hidden)(f)
132+
f = totp_option(hidden)(f)
114133
f = debug_option(hidden)(f)
115134
f = pass_state(f)
116135
return f

src/code42cli/sdk_client.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import py42.settings
33
import py42.settings.debug as debug
44
import requests
5+
from click import prompt
56
from click import secho
7+
from py42.exceptions import Py42MFARequiredError
68
from py42.exceptions import Py42UnauthorizedError
79
from requests.exceptions import ConnectionError
810

@@ -15,7 +17,7 @@
1517
logger = get_main_cli_logger()
1618

1719

18-
def create_sdk(profile, is_debug_mode):
20+
def create_sdk(profile, is_debug_mode, totp=None):
1921
if is_debug_mode:
2022
py42.settings.debug.level = debug.DEBUG
2123
if profile.ignore_ssl_errors == "True":
@@ -30,18 +32,24 @@ def create_sdk(profile, is_debug_mode):
3032
)
3133
py42.settings.verify_ssl_certs = False
3234
password = profile.get_password()
33-
return validate_connection(profile.authority_url, profile.username, password)
35+
return validate_connection(profile.authority_url, profile.username, password, totp)
3436

3537

36-
def validate_connection(authority_url, username, password):
38+
def validate_connection(authority_url, username, password, totp=None):
3739
try:
38-
return py42.sdk.from_local_account(authority_url, username, password)
40+
return py42.sdk.from_local_account(authority_url, username, password, totp=totp)
3941
except ConnectionError as err:
4042
logger.log_error(str(err))
41-
raise LoggedCLIError("Problem connecting to {}".format(authority_url))
43+
raise LoggedCLIError(f"Problem connecting to {authority_url}.")
44+
except Py42MFARequiredError:
45+
totp = prompt("Multi-factor authentication required. Enter TOTP", type=int)
46+
return validate_connection(authority_url, username, password, totp)
4247
except Py42UnauthorizedError as err:
4348
logger.log_error(str(err))
44-
raise Code42CLIError("Invalid credentials for user {}".format(username))
49+
if "INVALID_TIME_BASED_ONE_TIME_PASSWORD" in err.response.text:
50+
raise Code42CLIError(f"Invalid TOTP token for user {username}.")
51+
else:
52+
raise Code42CLIError(f"Invalid credentials for user {username}.")
4553
except Exception as err:
4654
logger.log_error(str(err))
4755
raise LoggedCLIError("Unknown problem validating connection.")

tests/cmds/test_profile.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import pytest
2+
from py42.exceptions import Py42MFARequiredError
3+
from requests import Response
4+
from requests.exceptions import HTTPError
25

36
from ..conftest import create_mock_profile
47
from code42cli import PRODUCT_NAME
@@ -208,6 +211,29 @@ def test_create_profile_with_password_option_if_credentials_valid_password_saved
208211
assert "Would you like to set a password?" not in result.output
209212

210213

214+
def test_create_profile_stores_password_and_prints_message_when_user_requires_mfa(
215+
runner, mocker, mock_verify, mock_cliprofile_namespace
216+
):
217+
mock_verify.side_effect = Py42MFARequiredError(HTTPError(response=Response()))
218+
result = runner.invoke(
219+
cli,
220+
[
221+
"profile",
222+
"create",
223+
"-n",
224+
"mfa",
225+
"-s",
226+
"bar",
227+
"-u",
228+
"baz",
229+
"--password",
230+
"pass",
231+
],
232+
)
233+
assert "Multi-factor account detected." in result.output
234+
mock_cliprofile_namespace.set_password.assert_called_once_with("pass", mocker.ANY)
235+
236+
211237
def test_create_profile_outputs_confirmation(
212238
runner, user_agreement, valid_connection, mock_cliprofile_namespace
213239
):

tests/test_sdk_client.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1+
from io import StringIO
2+
13
import py42.sdk
24
import py42.settings.debug as debug
35
import pytest
6+
from py42.exceptions import Py42MFARequiredError
47
from py42.exceptions import Py42UnauthorizedError
58
from requests import Response
69
from requests.exceptions import ConnectionError
10+
from requests.exceptions import HTTPError
711
from requests.exceptions import RequestException
812

913
from .conftest import create_mock_profile
1014
from code42cli.errors import Code42CLIError
1115
from code42cli.errors import LoggedCLIError
16+
from code42cli.main import cli
17+
from code42cli.options import CLIState
1218
from code42cli.sdk_client import create_sdk
1319
from code42cli.sdk_client import validate_connection
1420

@@ -114,5 +120,46 @@ def mock_get_password():
114120

115121

116122
def test_validate_connection_uses_given_credentials(mock_sdk_factory):
117-
assert validate_connection("Authority", "Test", "Password")
118-
mock_sdk_factory.assert_called_once_with("Authority", "Test", "Password")
123+
assert validate_connection("Authority", "Test", "Password", None)
124+
mock_sdk_factory.assert_called_once_with("Authority", "Test", "Password", totp=None)
125+
126+
127+
def test_validate_connection_when_mfa_required_exception_raised_prompts_for_totp(
128+
mocker, monkeypatch, mock_sdk_factory, capsys
129+
):
130+
monkeypatch.setattr("sys.stdin", StringIO("101010"))
131+
response = mocker.MagicMock(spec=Response)
132+
mock_sdk_factory.side_effect = [
133+
Py42MFARequiredError(HTTPError(response=response)),
134+
None,
135+
]
136+
validate_connection("Authority", "Test", "Password", None)
137+
output = capsys.readouterr()
138+
assert "Multi-factor authentication required. Enter TOTP:" in output.out
139+
140+
141+
def test_validate_connection_when_mfa_token_invalid_raises_expected_cli_error(
142+
mocker, mock_sdk_factory
143+
):
144+
response = mocker.MagicMock(spec=Response)
145+
response.text = '{"data":null,"error":[{"primaryErrorKey":"INVALID_TIME_BASED_ONE_TIME_PASSWORD","otherErrors":null}],"warnings":null}'
146+
mock_sdk_factory.side_effect = Py42UnauthorizedError(HTTPError(response=response))
147+
with pytest.raises(Code42CLIError) as err:
148+
validate_connection("Authority", "Test", "Password", "1234")
149+
assert str(err.value) == "Invalid TOTP token for user Test."
150+
151+
152+
def test_totp_option_when_passed_is_passed_to_sdk_initialization(
153+
mocker, profile, runner
154+
):
155+
mock_py42 = mocker.patch("code42cli.sdk_client.py42.sdk.from_local_account")
156+
cli_state = CLIState()
157+
totp = "1234"
158+
profile.authority_url = "example.com"
159+
profile.username = "user"
160+
profile.get_password.return_value = "password"
161+
cli_state._profile = profile
162+
runner.invoke(cli, ["users", "list", "--totp", totp], obj=cli_state)
163+
mock_py42.assert_called_once_with(
164+
profile.authority_url, profile.username, "password", totp=totp
165+
)

0 commit comments

Comments
 (0)