diff --git a/CHANGELOG.md b/CHANGELOG.md index f957e0304..0927ee193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,7 +146,6 @@ Please add a _short_ line describing the PR you make, if the PR implements a spe - Patch: Add a message when the project access would be fixed for a user. ([#446](https://github.com/ScilifelabDataCentre/dds_cli/pull/446)) - ## Sprint (2022-04-06 - 2022-04-20) - `motd` command to add new message of the day via new endpoint ([#449](https://github.com/ScilifelabDataCentre/dds_cli/pull/449)) @@ -154,6 +153,11 @@ Please add a _short_ line describing the PR you make, if the PR implements a spe - Pin versions in `requirements-dev.txt`: New version of `sphinx-click` makes `:nested: full` not work anymore (direct commit: https://github.com/ScilifelabDataCentre/dds_cli/commit/b91332b43e9cdee40a8132eab15e2fea3201bab6) ## Sprint (2022-04-20 - 2022-05-04) + - Patch: Update help message about `--principal-investigator` option ([#465](https://github.com/ScilifelabDataCentre/dds_cli/pull/465)) - Removed all CLI tests because needs redo ([#469](https://github.com/ScilifelabDataCentre/dds_cli/pull/469)) - (Re)Added parsing of project specific errors for `dds project access fix` and `dds user add -p` ([#491](https://github.com/ScilifelabDataCentre/dds_cli/pull/491)) + +## Sprint (2022-05-04 - 2022-05-18) + +- Enable use of app for second factor authentication instead of email. ([#259](https://github.com/ScilifelabDataCentre/dds_cli/pull/259)) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..1ecf8960c --- /dev/null +++ b/codecov.yml @@ -0,0 +1,6 @@ +coverage: + status: + project: + default: + threshold: 5% + patch: off diff --git a/dds_cli/__init__.py b/dds_cli/__init__.py index 84d469317..518c587d5 100644 --- a/dds_cli/__init__.py +++ b/dds_cli/__init__.py @@ -73,6 +73,8 @@ class DDSEndpoint: REVOKE_PROJECT_ACCESS = BASE_ENDPOINT + "/user/access/revoke" DISPLAY_USER_INFO = BASE_ENDPOINT + "/user/info" USER_ACTIVATION = BASE_ENDPOINT + "/user/activation" + USER_ACTIVATE_TOTP = BASE_ENDPOINT + "/user/totp/activate" + USER_ACTIVATE_HOTP = BASE_ENDPOINT + "/user/hotp/activate" # Authentication - user and project ENCRYPTED_TOKEN = BASE_ENDPOINT + "/user/encrypted_token" diff --git a/dds_cli/__main__.py b/dds_cli/__main__.py index 2f5d782e5..6627372cd 100644 --- a/dds_cli/__main__.py +++ b/dds_cli/__main__.py @@ -9,7 +9,6 @@ import itertools import logging import os -from re import T import sys # Installed @@ -340,8 +339,14 @@ def auth_group_command(_): # -- dds auth login -- # @auth_group_command.command(name="login") +@click.option( + "--totp", + type=str, + default=None, + help="2FA authentication via authentication app. Default is to use one-time authentication code via mail.", +) @click.pass_obj -def login(click_ctx): +def login(click_ctx, totp): """Start or renew an authenticated session. Creates or renews the authentication token stored in the '.dds_cli_token' file. @@ -353,7 +358,7 @@ def login(click_ctx): if no_prompt: LOG.warning("The --no-prompt flag is ignored for `dds auth login`") try: - with dds_cli.auth.Auth(token_path=click_ctx.get("TOKEN_PATH")): + with dds_cli.auth.Auth(token_path=click_ctx.get("TOKEN_PATH"), totp=totp): # Authentication token renewed in the init method. LOG.info("[green] :white_check_mark: Authentication token created![/green]") except ( @@ -407,6 +412,30 @@ def info(click_ctx): sys.exit(1) +@auth_group_command.command(name="twofactor") +def twofactor(): + """Configure your preferred method of two-factor authentication.""" + try: + LOG.info("Starting configuration of one-time authentication code method.") + auth_method_choice = questionary.select( + "Which method would you like to use?", choices=["Email", "Authenticator App", "Cancel"] + ).ask() + + if auth_method_choice == "Cancel": + LOG.info("Two-factor authentication method not configured.") + sys.exit(0) + elif auth_method_choice == "Authenticator App": + auth_method = "totp" + elif auth_method_choice == "Email": + auth_method = "hotp" + + with dds_cli.auth.Auth(authenticate=True, force_renew_token=False) as authenticator: + authenticator.twofactor(auth_method=auth_method) + except (dds_cli.exceptions.DDSCLIException, dds_cli.exceptions.ApiResponseError) as err: + LOG.error(err) + sys.exit(1) + + #################################################################################################### #################################################################################################### ## USER #################################################################################### USER ## diff --git a/dds_cli/auth.py b/dds_cli/auth.py index cbbeb3414..16fb46c29 100644 --- a/dds_cli/auth.py +++ b/dds_cli/auth.py @@ -1,9 +1,17 @@ -"""Data Delivery System saved authentication token manager.""" +"""Data Delivery System authentication manager.""" +# Standard library import logging +import getpass + +# Installed +import rich # Own modules +import dds_cli from dds_cli import base +from dds_cli import exceptions from dds_cli import user +import dds_cli.utils ############################################################################### # START LOGGING CONFIG ################################# START LOGGING CONFIG # @@ -23,15 +31,18 @@ class Auth(base.DDSBaseClass): def __init__( self, authenticate: bool = True, + force_renew_token: bool = True, # Only used if authenticate is True token_path: str = None, + totp: str = None, ): """Handle actions regarding session management in DDS.""" # Initiate DDSBaseClass to authenticate user super().__init__( authenticate=authenticate, method_check=False, - force_renew_token=True, # Only used if authenticate is True + force_renew_token=force_renew_token, token_path=token_path, + totp=totp, ) def check(self): @@ -51,3 +62,32 @@ def logout(self): LOG.info("[green] :white_check_mark: Successfully logged out![/green]") else: LOG.info("[green]Already logged out![/green]") + + def twofactor(self, auth_method: str = None): + if auth_method == "totp": + response_json, _ = dds_cli.utils.perform_request( + endpoint=dds_cli.DDSEndpoint.USER_ACTIVATE_TOTP, + headers=self.token, + method="post", + ) + else: + # Need to authenticate again since TOTP might have been lost + LOG.info( + "Activating authentication via email, please (re-)enter your username and password:" + ) + username = rich.prompt.Prompt.ask("DDS username") + password = getpass.getpass(prompt="DDS password: ") + + if password == "": + raise exceptions.AuthenticationError( + message="Non-empty password needed to be able to authenticate." + ) + + response_json, _ = dds_cli.utils.perform_request( + endpoint=dds_cli.DDSEndpoint.USER_ACTIVATE_HOTP, + headers=None, + method="post", + auth=(username, password), + ) + + LOG.info(response_json.get("message")) diff --git a/dds_cli/base.py b/dds_cli/base.py index 95f494e2d..8638949a1 100644 --- a/dds_cli/base.py +++ b/dds_cli/base.py @@ -52,6 +52,7 @@ def __init__( authenticate: bool = True, method_check: bool = True, force_renew_token: bool = False, + totp: str = None, no_prompt: bool = False, token_path: str = None, ): @@ -96,6 +97,7 @@ def __init__( force_renew_token=force_renew_token, no_prompt=no_prompt, token_path=token_path, + totp=totp, ) self.token = dds_user.token_dict diff --git a/dds_cli/user.py b/dds_cli/user.py index f78c7c709..d62dd935b 100644 --- a/dds_cli/user.py +++ b/dds_cli/user.py @@ -46,6 +46,7 @@ def __init__( force_renew_token: bool = False, no_prompt: bool = False, token_path: str = None, + totp: str = None, ): self.force_renew_token = force_renew_token self.no_prompt = no_prompt @@ -53,7 +54,7 @@ def __init__( self.token_path = token_path # Fetch encrypted JWT token or authenticate against API - self.__retrieve_token() + self.__retrieve_token(totp=totp) @property def token_dict(self): @@ -61,7 +62,7 @@ def token_dict(self): return {"Authorization": f"Bearer {self.token}"} # Private methods ######################### Private methods # - def __retrieve_token(self): + def __retrieve_token(self, totp: str = None): """Fetch saved token from file otherwise authenticate user and saves the new token.""" token_file = TokenFile(token_path=self.token_path) @@ -83,10 +84,10 @@ def __retrieve_token(self): ) else: LOG.info("Attempting to create the session token") - self.token = self.__authenticate_user() + self.token = self.__authenticate_user(totp=totp) token_file.save_token(self.token) - def __authenticate_user(self): + def __authenticate_user(self, totp: str = None): """Authenticates the username and password via a call to the API.""" LOG.debug("Starting authentication on the API.") @@ -116,40 +117,73 @@ def __authenticate_user(self): # Token received from API needs to be completed with a mfa timestamp partial_auth_token = response_json.get("token") + secondfactor_method = response_json.get("secondfactor_method") - # Verify 2fa email token - LOG.info( - "Please enter the one-time authentication code sent " - "to your email address (leave empty to exit):" - ) - done = False - while not done: - entered_one_time_code = rich.prompt.Prompt.ask("Authentication one-time code") - if entered_one_time_code == "": - raise exceptions.AuthenticationError( - message="Exited due to no one-time authentication code entered." - ) + totp_enabled = secondfactor_method == "TOTP" - if not entered_one_time_code.isdigit(): - LOG.info("Please enter a valid one-time code. It should consist of only digits.") - continue - if len(entered_one_time_code) != 8: - LOG.info( - "Please enter a valid one-time code. It should consist of 8 digits " - f"(you entered {len(entered_one_time_code)} digits)." + # Verify Second Factor + if totp: + if not totp_enabled: + raise exceptions.AuthenticationError( + "Authentication failed, you have not yet activated one-time authentication codes from authenticator app." ) - continue response_json, _ = dds_cli.utils.perform_request( dds_cli.DDSEndpoint.SECOND_FACTOR, method="get", headers={"Authorization": f"Bearer {partial_auth_token}"}, - json={"HOTP": entered_one_time_code}, - error_message="Failed to authenticate with second factor", + json={"TOTP": totp}, + error_message="Failed to authenticate with one-time authentication code", ) - # Step out of the while-loop - done = True + else: + if totp_enabled: + LOG.info( + "Please enter the one-time authentication code from your authenticator app." + ) + nr_digits = 6 + else: + LOG.info( + "Please enter the one-time authentication code sent " + "to your email address (leave empty to exit):" + ) + nr_digits = 8 + + done = False + while not done: + entered_one_time_code = rich.prompt.Prompt.ask("Authentication one-time code") + if entered_one_time_code == "": + raise exceptions.AuthenticationError( + message="Exited due to no one-time authentication code entered." + ) + + if not entered_one_time_code.isdigit(): + LOG.info( + "Please enter a valid one-time code. It should consist of only digits." + ) + continue + if len(entered_one_time_code) != nr_digits: + LOG.info( + f"Please enter a valid one-time code. It should consist of {nr_digits} digits " + f"(you entered {len(entered_one_time_code)} digits)." + ) + continue + + if totp_enabled: + json_request = {"TOTP": entered_one_time_code} + else: + json_request = {"HOTP": entered_one_time_code} + + response_json, _ = dds_cli.utils.perform_request( + dds_cli.DDSEndpoint.SECOND_FACTOR, + method="get", + headers={"Authorization": f"Bearer {partial_auth_token}"}, + json=json_request, + error_message="Failed to authenticate with second factor", + ) + + # Step out of the while-loop + done = True # Get token from response token = response_json.get("token") diff --git a/tests/test_utils.py b/tests/test_utils.py index 16d9fd756..4e254c90b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -136,3 +136,37 @@ def test_perform_request_add_user_errors() -> None: # Make sure that errors are parsed correctly assert "Invite error\ntest message\n - project_1\n - project_2" in str(exc_info.value) + + +def test_perform_request_activate_TOTP_error() -> None: + response_json: Dict = { + "message": "test message", + "title": "", + "description": "", + "pi": "", + "email": "", + } + with Mocker() as mock: + mock.post(DDSEndpoint.USER_ACTIVATE_TOTP, status_code=400, json=response_json) + with raises(DDSCLIException) as exc_info: + perform_request(endpoint=DDSEndpoint.USER_ACTIVATE_TOTP, headers={}, method="post") + + assert len(exc_info.value.args) == 1 + assert exc_info.value.args[0] == "API Request failed.: test message" + + +def test_perform_request_activate_HOTP_error() -> None: + response_json: Dict = { + "message": "test message", + "title": "", + "description": "", + "pi": "", + "email": "", + } + with Mocker() as mock: + mock.post(DDSEndpoint.USER_ACTIVATE_HOTP, status_code=400, json=response_json) + with raises(DDSCLIException) as exc_info: + perform_request(endpoint=DDSEndpoint.USER_ACTIVATE_HOTP, headers={}, method="post") + + assert len(exc_info.value.args) == 1 + assert exc_info.value.args[0] == "API Request failed.: test message"