Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Possible to trigger TOTP activation and login using TOTP #259

Merged
merged 28 commits into from
May 13, 2022
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8f3880f
Possible to trigger TOTP activation and login using TOTP
alneberg Feb 24, 2022
94b4f15
Merge with dev
alneberg Feb 24, 2022
9ad0ef5
Updated and formatted changelog
alneberg Feb 24, 2022
9b87a07
Merge with dev
alneberg Mar 31, 2022
72d39f4
Fix for some lost merge conflicts
alneberg Mar 31, 2022
4145afa
More merge conflict fixes
alneberg Mar 31, 2022
4f31ee2
Possible to use TOTP interactively
alneberg Apr 7, 2022
26ac4be
Improved user interface for activating TOTP
alneberg Apr 7, 2022
2250096
Update totp url
aanil Apr 13, 2022
f225c4d
Merge upstream
aanil Apr 13, 2022
5b73a2d
Begin supporting HOTP switch back
aanil Apr 20, 2022
16b48e9
Add support for switching back to HOTP
aanil Apr 20, 2022
583677b
black
aanil Apr 20, 2022
e09b297
Inas suggestions
aanil Apr 21, 2022
7f06690
Resolve conflicts and merge dev
aanil Apr 27, 2022
a122d6c
Merge branch 'dev' into TOTP_attempt
i-oden May 4, 2022
3370786
Indentation error fix
alneberg May 5, 2022
00622db
Update to perform_request
aanil May 5, 2022
52c297c
Possible to activate email as authentication
alneberg May 5, 2022
47311e3
Merge branch 'dev' of https://github.com/ScilifelabDataCentre/dds_cli…
aanil May 11, 2022
6021949
Added tiny tests for TOTP and HOTP activation errors
alneberg May 12, 2022
74b7982
No message in tests for TOTP/HOTP
alneberg May 12, 2022
2b2937b
Codecov config file
alneberg May 12, 2022
6f1ed1d
Merge with dev
alneberg May 12, 2022
1990810
Changelog
alneberg May 12, 2022
cd5673c
Attempt to fix tests
alneberg May 12, 2022
df078e2
perform request returns tuple
alneberg May 12, 2022
e9b465b
Update CHANGELOG.md
i-oden May 13, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,18 @@ 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))
- Patch: Message in docstrings to urge users to reauthenticate before upload and download ([#450](https://github.com/ScilifelabDataCentre/dds_cli/pull/450))
- 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)
- 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>)
i-oden marked this conversation as resolved.
Show resolved Hide resolved

## 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))
6 changes: 6 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
coverage:
status:
project:
default:
threshold: 5%
patch: off
2 changes: 2 additions & 0 deletions dds_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 32 additions & 3 deletions dds_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import itertools
import logging
import os
from re import T
import sys

# Installed
Expand Down Expand Up @@ -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.
Expand All @@ -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 (
Expand Down Expand Up @@ -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"
i-oden marked this conversation as resolved.
Show resolved Hide resolved

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 ##
Expand Down
44 changes: 42 additions & 2 deletions dds_cli/auth.py
Original file line number Diff line number Diff line change
@@ -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 #
Expand All @@ -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):
Expand All @@ -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"))
2 changes: 2 additions & 0 deletions dds_cli/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down Expand Up @@ -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

Expand Down
90 changes: 62 additions & 28 deletions dds_cli/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,23 @@ 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
self.token = None
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):
"""Get token as authorization dict for requests."""
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)

Expand All @@ -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.")

Expand Down Expand Up @@ -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")
Expand Down
34 changes: 34 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"