From 174f67050b09a7be1e1f70d0411cc0bc3c48a4d9 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 18 Mar 2024 12:55:34 +1100 Subject: [PATCH 1/7] WIP: POC - Console interactive selection of mfa and role Add the ability to select the MFA method and the role through python inquirer as an optional dependency. --- setup.py | 3 +++ tokendito/user.py | 54 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 9ddce484..199d7e76 100755 --- a/setup.py +++ b/setup.py @@ -60,6 +60,9 @@ license=about["__license__"], zip_safe=False, install_requires=[required], + extras_require={ + 'inquirer': ['inquirer'], + }, entry_points={ "console_scripts": ["tokendito=tokendito.__main__:main"], }, diff --git a/tokendito/user.py b/tokendito/user.py index be88f39b..d2e40b57 100644 --- a/tokendito/user.py +++ b/tokendito/user.py @@ -34,6 +34,14 @@ except ModuleNotFoundError: pass +INQUIRER_AVAILABLE = False +try: + import inquirer + + INQUIRER_AVAILABLE = True +except ModuleNotFoundError: + pass + logger = logging.getLogger(__name__) mask_items = [] @@ -447,25 +455,40 @@ def select_preferred_mfa_index(mfa_options, factor_key="provider", subfactor_key """ logger.debug("Show all the MFA options to the users.") logger.debug(json.dumps(mfa_options)) - print("\nSelect your preferred MFA method and press Enter:") longest_index = len(str(len(mfa_options))) longest_factor_name = max([len(d[factor_key]) for d in mfa_options]) longest_subfactor_name = max([len(d[subfactor_key]) for d in mfa_options]) factor_info_indent = max([len(mfa_option_info(d)) for d in mfa_options]) + mfa_list = [] for i, mfa_option in enumerate(mfa_options): factor_id = mfa_option.get("id", "Not presented") factor_info = mfa_option_info(mfa_option) mfa = mfa_option.get(subfactor_key, "Not presented") provider = mfa_option.get(factor_key, "Not presented") - print( + mfa_item = ( f"[{i: >{longest_index}}] " f"{provider: <{longest_factor_name}} " f"{mfa: <{longest_subfactor_name}} " f"{factor_info: <{factor_info_indent}} " f"Id: {factor_id}" ) + mfa_list.append((mfa_item, i)) + + if INQUIRER_AVAILABLE: + questions = [ + inquirer.List('mfa_selection', + message="Select your preferred MFA method and press Enter:", + choices=mfa_list, + ), + ] + answers = inquirer.prompt(questions) + return answers['mfa_selection'] + + print("\nSelect your preferred MFA method and press Enter:") + for text, i in mfa_list: + print(text) user_input = collect_integer(len(mfa_options)) @@ -492,13 +515,13 @@ def prompt_role_choices(aut_tiles): aliases_mapping.append((tile["label"], role.split(":")[4], role, url)) logger.debug("Ask user to select role") - print("\nPlease select one of the following:") longest_alias = max(len(i[1]) for i in aliases_mapping) longest_index = len(str(len(aliases_mapping))) aliases_mapping = sorted(aliases_mapping) print_label = "" + role_list = [] for i, data in enumerate(aliases_mapping): label, alias, role, _ = data padding_index = longest_index - len(str(i)) @@ -506,8 +529,29 @@ def prompt_role_choices(aut_tiles): print_label = label print(f"\n{label}:") - print(f"[{i}] {padding_index * ' '}" f"{alias: <{longest_alias}} {role}") - + role_item = f"[{i}] {padding_index * ' '}" f"{alias: <{longest_alias}} {role}" + role_list.append((role_item, (aliases_mapping[i][2], aliases_mapping[i][3]))) + + if INQUIRER_AVAILABLE: + questions = [ + inquirer.List('role_selection', + message="Please select one of the following:", + choices=role_list, + ), + ] + answers = inquirer.prompt(questions) + return answers['role_selection'] + + # print("\nSelect your preferred MFA method and press Enter:") + # for text, i in mfa_list: + # print(text) + # + # user_input = collect_integer(len(mfa_options)) + # + # return user_input + print("\nPlease select one of the following:") + for role_item, i in role_list: + print(role_item) user_input = collect_integer(len(aliases_mapping)) selected_role = (aliases_mapping[user_input][2], aliases_mapping[user_input][3]) logger.debug(f"Selected role [{user_input}]") From 472bc297f8c1b761b215acea06a53fa5eb4271d5 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 18 Mar 2024 13:17:01 +1100 Subject: [PATCH 2/7] Also log the selected role under the inquirer path --- tokendito/user.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tokendito/user.py b/tokendito/user.py index d2e40b57..0e514ec5 100644 --- a/tokendito/user.py +++ b/tokendito/user.py @@ -540,7 +540,8 @@ def prompt_role_choices(aut_tiles): ), ] answers = inquirer.prompt(questions) - return answers['role_selection'] + logger.debug(f"Selected role [{answers.get('role_selection')}]") + return answers.get('role_selection') # print("\nSelect your preferred MFA method and press Enter:") # for text, i in mfa_list: From 7d7a3af7946b907c795f0d1f55ec61c38b1385e1 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 18 Mar 2024 15:01:09 +1100 Subject: [PATCH 3/7] Moto 5 and above does not support mock_sts. Requires 4 or lower. --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c718ac69..b1dd4af2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,7 @@ flake8>=5.0.0 flake8-black flake8-docstrings flake8-import-order -moto +moto<5.0.0 pep8-naming pexpect pydocstyle From ae9944910dfc5b617fa6744e522ac9af6783011b Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 18 Mar 2024 16:16:13 +1100 Subject: [PATCH 4/7] No need for .get protection on the inquirer response. Better to cause exception as the variable and response are in the same code block. --- tokendito/user.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tokendito/user.py b/tokendito/user.py index 0e514ec5..7c0167a8 100644 --- a/tokendito/user.py +++ b/tokendito/user.py @@ -456,6 +456,7 @@ def select_preferred_mfa_index(mfa_options, factor_key="provider", subfactor_key logger.debug("Show all the MFA options to the users.") logger.debug(json.dumps(mfa_options)) + longest_index = len(str(len(mfa_options))) longest_factor_name = max([len(d[factor_key]) for d in mfa_options]) longest_subfactor_name = max([len(d[subfactor_key]) for d in mfa_options]) @@ -541,7 +542,7 @@ def prompt_role_choices(aut_tiles): ] answers = inquirer.prompt(questions) logger.debug(f"Selected role [{answers.get('role_selection')}]") - return answers.get('role_selection') + return answers['role_selection'] # print("\nSelect your preferred MFA method and press Enter:") # for text, i in mfa_list: From 148fc06cd41ec344b362b912335bc6e4895281c9 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 18 Mar 2024 16:16:47 +1100 Subject: [PATCH 5/7] Fix up test cases by disabling inquirer by default. --- tests/unit/test_user.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_user.py b/tests/unit/test_user.py index b6896635..e17828d2 100644 --- a/tests/unit/test_user.py +++ b/tests/unit/test_user.py @@ -560,6 +560,9 @@ def test_mfa_option_info(factor_type, output): def test_select_preferred_mfa_index(mocker, sample_json_response): """Test whether the function returns index entered by user.""" + # Don't enable inquirer for these tests + mocker.patch('tokendito.user.INQUIRER_AVAILABLE', False) + from tokendito.user import select_preferred_mfa_index primary_auth = sample_json_response @@ -568,7 +571,6 @@ def test_select_preferred_mfa_index(mocker, sample_json_response): mocker.patch("tokendito.user.collect_integer", return_value=output) assert select_preferred_mfa_index(mfa_options) == output - @pytest.mark.parametrize( "email", [ @@ -577,6 +579,9 @@ def test_select_preferred_mfa_index(mocker, sample_json_response): ) def test_select_preferred_mfa_index_output(email, capsys, mocker, sample_json_response): """Test whether the function gives correct output.""" + + # Don't enable inquirer for these tests + mocker.patch('tokendito.user.INQUIRER_AVAILABLE', False) from tokendito.config import config from tokendito.user import select_preferred_mfa_index From 0d4e968d6ec9694c3a34a7dac9a3d3b3824a4445 Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 18 Mar 2024 16:21:11 +1100 Subject: [PATCH 6/7] Add test case for inquirer mfa selection. --- tests/unit/test_user.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/test_user.py b/tests/unit/test_user.py index e17828d2..92430c51 100644 --- a/tests/unit/test_user.py +++ b/tests/unit/test_user.py @@ -571,6 +571,21 @@ def test_select_preferred_mfa_index(mocker, sample_json_response): mocker.patch("tokendito.user.collect_integer", return_value=output) assert select_preferred_mfa_index(mfa_options) == output + +def test_select_preferred_mfa_index_inquirer(mocker, sample_json_response): + """Test whether the function returns index entered by user.""" + from tokendito.user import select_preferred_mfa_index + from tokendito.user import INQUIRER_AVAILABLE + + if not INQUIRER_AVAILABLE: + pytest.skip("No items found") + else: + primary_auth = sample_json_response + mfa_options = primary_auth["okta_response_mfa"]["_embedded"]["factors"] + for output in mfa_options: + mocker.patch("tokendito.user.inquirer.prompt", return_value={"mfa_selection": output}) + assert select_preferred_mfa_index(mfa_options) == output + @pytest.mark.parametrize( "email", [ From 54cf0228c360aaee905fc29f71850d059a4462ce Mon Sep 17 00:00:00 2001 From: matt Date: Mon, 18 Mar 2024 16:22:15 +1100 Subject: [PATCH 7/7] Remove unused code block --- tokendito/user.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tokendito/user.py b/tokendito/user.py index 7c0167a8..4015c336 100644 --- a/tokendito/user.py +++ b/tokendito/user.py @@ -544,13 +544,6 @@ def prompt_role_choices(aut_tiles): logger.debug(f"Selected role [{answers.get('role_selection')}]") return answers['role_selection'] - # print("\nSelect your preferred MFA method and press Enter:") - # for text, i in mfa_list: - # print(text) - # - # user_input = collect_integer(len(mfa_options)) - # - # return user_input print("\nPlease select one of the following:") for role_item, i in role_list: print(role_item)