Skip to content

Commit

Permalink
Merge pull request #1175 from ScilifelabDataCentre/dev
Browse files Browse the repository at this point in the history
TOTP added and deletion bug fixed
  • Loading branch information
i-oden authored May 13, 2022
2 parents fcd19b9 + 45b7f9e commit 372ecd5
Show file tree
Hide file tree
Showing 24 changed files with 1,056 additions and 50 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,7 @@ Please add a _short_ line describing the PR you make, if the PR implements a spe
- Bug: API returning float again and CLI `--size` flag works again ([#1162](https://github.com/ScilifelabDataCentre/dds_web/pull/1162))
- Bug: Check for timestamp `0000-00-00 00:00:00` added and invite deleted ([#1163](https://github.com/ScilifelabDataCentre/dds_web/pull/1163))
- Add documentation of status codes in `api/project.py` ([#1164](https://github.com/ScilifelabDataCentre/dds_web/pull/1164))
- Add ability to switch to using TOTP and back to HOTP for MFA ([#936](https://github.com/scilifelabdatacentre/dds_web/issues/936))
- Patch: Fix the warning in web for too soon TOTP login (within 90 seconds) ([#1173](https://github.com/ScilifelabDataCentre/dds_web/pull/1173))
- Bug: Do not remove the bucket when emptying the project ([#1172](https://github.com/ScilifelabDataCentre/dds_web/pull/1172))
- New `add-missing-buckets` argument option to the `lost-files` flask command ([#1174](https://github.com/ScilifelabDataCentre/dds_web/pull/1174))
12 changes: 11 additions & 1 deletion dds_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ def update_uploaded_file_with_log(project, path_to_log_file):


@click.command("lost-files")
@click.argument("action_type", type=click.Choice(["find", "list", "delete"]))
@click.argument("action_type", type=click.Choice(["find", "list", "delete", "add-missing-buckets"]))
@flask.cli.with_appcontext
def lost_files_s3_db(action_type: str):
"""
Expand All @@ -494,6 +494,7 @@ def lost_files_s3_db(action_type: str):
"""
from dds_web.database import models
import boto3
from dds_web.utils import bucket_is_valid

for unit in models.Unit.query:
session = boto3.session.Session()
Expand All @@ -514,6 +515,15 @@ def lost_files_s3_db(action_type: str):
)
except resource.meta.client.exceptions.NoSuchBucket:
flask.current_app.logger.warning("Missing bucket %s", project.bucket)
if action_type == "add-missing-buckets":
valid, message = bucket_is_valid(bucket_name=project.bucket)
if not valid:
flask.current_app.logger.warning(
f"Could not create bucket '{project.bucket}' for project '{project.public_id}': {message}"
)
else:
resource.create_bucket(Bucket=project.bucket)
flask.current_app.logger.info(f"Bucket '{project.bucket}' created.")
continue

try:
Expand Down
6 changes: 6 additions & 0 deletions dds_web/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ def output_json(data, code, headers=None):
api.add_resource(user.DeleteUserSelf, "/user/delete_self", endpoint="delete_user_self")
api.add_resource(user.RemoveUserAssociation, "/user/access/revoke", endpoint="revoke_from_project")
api.add_resource(user.UserActivation, "/user/activation", endpoint="user_activation")
api.add_resource(
user.RequestHOTPActivation, "/user/hotp/activate", endpoint="request_hotp_activation"
)
api.add_resource(
user.RequestTOTPActivation, "/user/totp/activate", endpoint="request_totp_activation"
)
api.add_resource(user.UnitUsers, "/unit/users", endpoint="unit_users")

# Super Admins ###################################################################### Super Admins #
Expand Down
10 changes: 6 additions & 4 deletions dds_web/api/api_s3_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,18 @@ def get_s3_info(self):
)

@bucket_must_exists
def remove_bucket(self, *args, **kwargs):
"""Removes all contents from the project specific s3 bucket."""
def remove_bucket_contents(self, delete_bucket=False, *_, **__):
"""Removed all contents within a project specific s3 bucket."""
# Get bucket object
bucket = self.resource.Bucket(self.project.bucket)

# Delete objects first
bucket.objects.all().delete()

# Delete bucket
bucket.delete()
# Delete bucket if chosen
if delete_bucket:
bucket.delete()

bucket = None

@bucket_must_exists
Expand Down
8 changes: 4 additions & 4 deletions dds_web/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ def delete_project(self, project: models.Project, current_time: datetime.datetim

try:
# Deletes files (also commits session in the function - possibly refactor later)
RemoveContents().delete_project_contents(project=project)
RemoveContents().delete_project_contents(project=project, delete_bucket=True)
self.rm_project_user_keys(project=project)

# Delete metadata from project row
Expand Down Expand Up @@ -319,7 +319,7 @@ def archive_project(

try:
# Deletes files (also commits session in the function - possibly refactor later)
RemoveContents().delete_project_contents(project=project)
RemoveContents().delete_project_contents(project=project, delete_bucket=True)
delete_message = f"\nAll files in {project.public_id} deleted"
self.rm_project_user_keys(project=project)

Expand Down Expand Up @@ -545,12 +545,12 @@ def delete(self):
return {"removed": True}

@staticmethod
def delete_project_contents(project):
def delete_project_contents(project, delete_bucket=False):
"""Remove project contents"""
# Delete from cloud
with ApiS3Connector(project=project) as s3conn:
try:
s3conn.remove_bucket()
s3conn.remove_bucket_contents(delete_bucket=delete_bucket)
except botocore.client.ClientError as err:
raise DeletionError(message=str(err), project=project.public_id) from err

Expand Down
25 changes: 23 additions & 2 deletions dds_web/api/schemas/token_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ class TokenSchema(marshmallow.Schema):
),
)

TOTP = marshmallow.fields.String(
required=False,
validate=marshmallow.validate.And(
marshmallow.validate.Length(min=6, max=6),
marshmallow.validate.ContainsOnly("0123456789"),
),
)

class Meta:
unknown = marshmallow.EXCLUDE

Expand All @@ -38,11 +46,24 @@ def validate_mfa(self, data, **kwargs):
"""Verify HOTP (authentication One-Time code) is correct."""

# This can be easily extended to require at least one MFA method
if "HOTP" not in data:
if ("HOTP" not in data) and ("TOTP" not in data):
raise marshmallow.exceptions.ValidationError("MFA method not supplied")

user = auth.current_user()
if "HOTP" in data:

if user.totp_enabled:
value = data.get("TOTP")
if not value:
raise marshmallow.ValidationError(
"Your account is setup to use time-based one-time authentication codes, but you entered a one-time authentication code from email."
)
# Raises authentication error if TOTP is incorrect
user.verify_TOTP(value.encode())
else:
value = data.get("HOTP")
if not value:
raise marshmallow.ValidationError(
"Your account is setup to use one-time authentication code via email, you cannot authenticate with time-based one-time authentication codes."
)
# Raises authenticationerror if invalid
user.verify_HOTP(value.encode())
130 changes: 130 additions & 0 deletions dds_web/api/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -959,12 +959,14 @@ class EncryptedToken(flask_restful.Resource):
@basic_auth.login_required
@logging_bind_request
def get(self):
secondfactor_method = "TOTP" if auth.current_user().totp_enabled else "HOTP"
return {
"message": "Please take this token to /user/second_factor to authenticate with MFA!",
"token": encrypted_jwt_token(
username=auth.current_user().username,
sensitive_content=flask.request.authorization.get("password"),
),
"secondfactor_method": secondfactor_method,
}


Expand All @@ -982,6 +984,134 @@ def get(self):
return {"token": update_token_with_mfa(token_claims)}


class RequestTOTPActivation(flask_restful.Resource):
"""Request to switch from HOTP to TOTP for second factor authentication."""

@auth.login_required
def post(self):
user = auth.current_user()
if user.totp_enabled:
return {
"message": "Nothing to do, two-factor authentication with app is already enabled for this user."
}

# Not really necessary to encrypt this
token = encrypted_jwt_token(
username=user.username,
sensitive_content=None,
expires_in=datetime.timedelta(
seconds=3600,
),
additional_claims={"act": "totp"},
)

link = flask.url_for("auth_blueprint.activate_totp", token=token, _external=True)
# Send activation token to email to work as a validation step
# TODO: refactor this since the email sending code is replicated in many places
recipients = [user.primary_email]

# Fill in email subject with sentence subject
subject = f"Request to activate two-factor with authenticator app for SciLifeLab Data Delivery System"

msg = flask_mail.Message(
subject,
recipients=recipients,
)

# Need to attach the image to be able to use it
msg.attach(
"scilifelab_logo.png",
"image/png",
open(
os.path.join(flask.current_app.static_folder, "img/scilifelab_logo.png"), "rb"
).read(),
"inline",
headers=[
["Content-ID", "<Logo>"],
],
)

msg.body = flask.render_template(
f"mail/request_activate_totp.txt",
link=link,
)
msg.html = flask.render_template(
f"mail/request_activate_totp.html",
link=link,
)

AddUser.send_email_with_retry(msg)
return {
"message": "Please check your email and follow the attached link to activate two-factor with authenticator app."
}


class RequestHOTPActivation(flask_restful.Resource):
"""Request to switch from TOTP to HOTP for second factor authentication"""

# Using Basic auth since TOTP might have been lost, will still need access to email
@basic_auth.login_required
def post(self):

user = auth.current_user()
json_info = flask.request.json

if not user.totp_enabled:
return {
"message": "Nothing to do, two-factor authentication with email is already enabled for this user."
}

# Not really necessary to encrypt this
token = encrypted_jwt_token(
username=user.username,
sensitive_content=None,
expires_in=datetime.timedelta(
seconds=3600,
),
additional_claims={"act": "hotp"},
)

link = flask.url_for("auth_blueprint.activate_hotp", token=token, _external=True)
# Send activation token to email to work as a validation step
# TODO: refactor this since the email sending code is replicated in many places
recipients = [user.primary_email]

# Fill in email subject with sentence subject
subject = f"Request to activate two-factor authentication with email for SciLifeLab Data Delivery System"

msg = flask_mail.Message(
subject,
recipients=recipients,
)

# Need to attach the image to be able to use it
msg.attach(
"scilifelab_logo.png",
"image/png",
open(
os.path.join(flask.current_app.static_folder, "img/scilifelab_logo.png"), "rb"
).read(),
"inline",
headers=[
["Content-ID", "<Logo>"],
],
)

msg.body = flask.render_template(
f"mail/request_activate_hotp.txt",
link=link,
)
msg.html = flask.render_template(
f"mail/request_activate_hotp.html",
link=link,
)

AddUser.send_email_with_retry(msg)
return {
"message": "Please check your email and follow the attached link to activate two-factor with email."
}


class ShowUsage(flask_restful.Resource):
"""Calculate and display the amount of GB hours and the total cost."""

Expand Down
Loading

0 comments on commit 372ecd5

Please sign in to comment.