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 16 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
1 change: 1 addition & 0 deletions dds_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ 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"

# Authentication - user and project
ENCRYPTED_TOKEN = BASE_ENDPOINT + "/user/encrypted_token"
Expand Down
34 changes: 32 additions & 2 deletions dds_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,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 +359,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 +413,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
26 changes: 25 additions & 1 deletion dds_cli/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"""Data Delivery System saved authentication token manager."""
import logging
import requests
import simplejson

# Own modules
import dds_cli
from dds_cli import base
from dds_cli import user

Expand All @@ -23,15 +26,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 +57,21 @@ 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):
try:
response = requests.put(
dds_cli.DDSEndpoint.USER_ACTIVATE_TOTP,
headers=self.token,
json={"activate_totp": True if auth_method == "totp" else False},
)
response_json = response.json()
except requests.exceptions.RequestException as err:
raise dds_cli.exceptions.ApiRequestError(message=str(err))
except simplejson.JSONDecodeError as err:
raise dds_cli.exceptions.ApiResponseError(message=str(err))

if not response.ok:
raise dds_cli.exceptions.ApiResponseError(message=response.reason)

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
77 changes: 55 additions & 22 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,35 +117,67 @@ 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 == "":
totp_enabled = secondfactor_method == "TOTP"

# Verify Second Factor
if totp:
if not totp_enabled:
raise exceptions.AuthenticationError(
message="Exited due to no one-time authentication code entered."
"Authentication failed, you have not yet activated one-time authentication codes from authenticator app."
)
response_json = dds_cli.utils.perform_request(
dds_cli.DDSEndpoint.SECOND_FACTOR,
method="get",
headers={"Authorization": f"Bearer {partial_auth_token}"},
json={"TOTP": totp},
error_message="Failed to authenticate with one-time authentication code",
)

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:
else:
if totp_enabled:
LOG.info(
"Please enter a valid one-time code. It should consist of 8 digits "
f"(you entered {len(entered_one_time_code)} digits)."
"Please enter the one-time authentication code from your authenticator app."
)
continue
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={"HOTP": entered_one_time_code},
json=json_request,
error_message="Failed to authenticate with second factor",
)

Expand Down