Skip to content

Commit

Permalink
Merge pull request #1248 from ScilifelabDataCentre/dev
Browse files Browse the repository at this point in the history
New endpoint for deactivating TOTP for user
  • Loading branch information
i-oden authored Aug 18, 2022
2 parents eb61cf0 + 999efba commit 5a0cbf9
Show file tree
Hide file tree
Showing 21 changed files with 1,768 additions and 360 deletions.
2 changes: 2 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ Please delete options that are not relevant.
- [ ] Any dependent changes have been merged and published in downstream modules
- [ ] Rebase/merge the branch which this PR is made to
- [ ] Changes to the database schema: A new migration is included in the PR
- [ ] Product Owner / Scrum Master: This PR is made to the `master` branch and I have updated the [version](../dds_web/version.py)
- [ ] I am bumping the major version (e.g. 1.x.x to 2.x.x) and I have made the corresponding changes to the CLI version

## Formatting and documentation

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,7 @@ Please add a _short_ line describing the PR you make, if the PR implements a spe
- Do not send one time code to email if the email 2fa is getting activated ([#1236](https://github.com/ScilifelabDataCentre/dds_web/pull/1236))
- Raise AccessDeniedError with message when token specified but user not existent ([#1235](https://github.com/ScilifelabDataCentre/dds_web/pull/1235))
- Display multiple MOTDS ([#1212](https://github.com/ScilifelabDataCentre/dds_web/pull/1212))

## Sprint (2022-08-18 - 2022-09-02)

- Allow Super Admins to deactivate user 2FA via authenticator app ([#1247](https://github.com/ScilifelabDataCentre/dds_web/pull/1247))
3 changes: 3 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
coverage:
range: 60..90
round: down
precision: 2
status:
project:
default:
Expand Down
5 changes: 5 additions & 0 deletions dds_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,13 @@ def create_app(testing=False, database_uri=None):
@app.before_request
def prepare():
"""Populate flask globals for template rendering"""
from dds_web.utils import verify_cli_version
from dds_web.utils import get_active_motds

# Verify cli version compatible
if "api/v1" in flask.request.path:
verify_cli_version(version_cli=flask.request.headers.get("X-Cli-Version"))

# Get message of the day
flask.g.motd = get_active_motds()

Expand Down
3 changes: 3 additions & 0 deletions dds_web/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ def output_json(data, code, headers=None):
api.add_resource(superadmin_only.AllUnits, "/unit/info/all", endpoint="all_units")
api.add_resource(superadmin_only.MOTD, "/motd", endpoint="motd")
api.add_resource(superadmin_only.FindUser, "/user/find", endpoint="find_user")
api.add_resource(
superadmin_only.ResetTwoFactor, "/user/totp/deactivate", endpoint="reset_user_hotp"
)

# Invoicing ############################################################################ Invoicing #
api.add_resource(user.ShowUsage, "/usage", endpoint="usage")
28 changes: 19 additions & 9 deletions dds_web/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ class ProjectStatus(flask_restful.Resource):
@handle_validation_errors
def get(self):
"""Get current project status and optionally entire status history"""
# Verify project ID and access
project = project_schemas.ProjectRequiredSchema().load(flask.request.args)
# Get project ID, project and verify access
project_id = dds_web.utils.get_required_item(obj=flask.request.args, req="project")
project = dds_web.utils.collect_project(project_id=project_id)
dds_web.utils.verify_project_access(project=project)

# Get current status and deadline
return_info = {"current_status": project.current_status}
Expand All @@ -79,8 +81,10 @@ def get(self):
@handle_validation_errors
def post(self):
"""Update Project Status."""
# Verify project ID and access
project = project_schemas.ProjectRequiredSchema().load(flask.request.args)
# Get project ID, project and verify access
project_id = dds_web.utils.get_required_item(obj=flask.request.args, req="project")
project = dds_web.utils.collect_project(project_id=project_id)
dds_web.utils.verify_project_access(project=project)

# Check if valid status
json_input = flask.request.json
Expand Down Expand Up @@ -290,7 +294,9 @@ def delete_project(self, project: models.Project, current_time: datetime.datetim
flask.current_app.logger.exception(err)
db.session.rollback()
raise DeletionError(
project=project.public_id, message="Server Error: Status was not updated"
project=project.public_id,
message="Server Error: Status was not updated",
pass_message=True,
) from err

delete_message = (
Expand Down Expand Up @@ -331,7 +337,9 @@ def archive_project(
flask.current_app.logger.exception(err)
db.session.rollback()
raise DeletionError(
project=project.public_id, message="Server Error: Status was not updated"
project=project.public_id,
message="Server Error: Status was not updated",
pass_message=True,
) from err

return (
Expand Down Expand Up @@ -367,8 +375,10 @@ class GetPublic(flask_restful.Resource):
@handle_validation_errors
def get(self):
"""Get public key from database."""
# Verify project ID and access
project = project_schemas.ProjectRequiredSchema().load(flask.request.args)
# Get project ID, project and verify access
project_id = dds_web.utils.get_required_item(obj=flask.request.args, req="project")
project = dds_web.utils.collect_project(project_id=project_id)
dds_web.utils.verify_project_access(project=project)

flask.current_app.logger.debug("Getting the public key.")

Expand Down Expand Up @@ -466,7 +476,7 @@ def format_project_dict(self, current_user):
raise DatabaseError(
message=str(err),
alt_message=(
"Could not get users project access information."
"Could not get users project access information"
+ (
": Database malfunction."
if isinstance(err, sqlalchemy.exc.OperationalError)
Expand Down
30 changes: 30 additions & 0 deletions dds_web/api/superadmin_only.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,33 @@ def get(self):
return {
"exists": models.User.query.filter_by(username=user_to_find).one_or_none() is not None
}


class ResetTwoFactor(flask_restful.Resource):
"""Deactivate TOTP and activate HOTP for other user, e.g. if phone lost."""

@auth.login_required(role=["Super Admin"])
@logging_bind_request
@json_required
@handle_db_error
def put(self):
"""Change totp to hotp."""
# Check that username is specified
username: str = flask.request.json.get("username")
if not username:
raise ddserr.DDSArgumentError(message="Username required to reset 2FA to HOTP")

# Verify valid user
user: models.User = models.User.query.filter_by(username=username).one_or_none()
if not user:
raise ddserr.DDSArgumentError(message=f"The user doesn't exist: {username}")

# TOTP needs to be active in order to deactivate
if not user.totp_enabled:
raise ddserr.DDSArgumentError(message="TOTP is already deactivated for this user.")

user.deactivate_totp()

return {
"message": f"TOTP has been deactivated for user: {user.username}. They can now use 2FA via email during authentication."
}
11 changes: 11 additions & 0 deletions dds_web/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,14 @@ def __init__(self, message="Invalid role."):

super().__init__(message)
general_logger.warning(message)


class VersionMismatchError(LoggedHTTPException):

code = http.HTTPStatus.FORBIDDEN

def __init__(
self, message="You're using an old CLI version, please upgrade to the latest one."
):
super().__init__(message)
general_logger.warning(message)
69 changes: 64 additions & 5 deletions dds_web/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
# Installed
from contextlib import contextmanager
import flask
from dds_web.errors import AccessDeniedError
from dds_web.errors import (
AccessDeniedError,
VersionMismatchError,
DDSArgumentError,
NoSuchProjectError,
)
import flask_mail
import flask_login
import requests
import requests_cache
import simplejson

import werkzeug
import sqlalchemy

# # imports related to scheduling
import marshmallow
Expand All @@ -30,13 +33,69 @@
# Own modules
from dds_web.database import models
from dds_web import auth, mail
from dds_web.version import __version__

####################################################################################################
# VALIDATORS ########################################################################## VALIDATORS #
####################################################################################################

# General ################################################################################ General #

# Cannot have type hint for return due to models.Project giving circular import
def collect_project(project_id: str):
"""Get project object from database."""
project = models.Project.query.filter(
models.Project.public_id == sqlalchemy.func.binary(project_id)
).one_or_none()
if not project:
raise NoSuchProjectError(project=project_id)

return project


def get_required_item(req: str, obj: werkzeug.datastructures.ImmutableMultiDict = None) -> str:
"""Get value from dict."""
error_message = f"Missing required information: '{req}'"
if not obj:
raise DDSArgumentError(message=error_message)

req_val = obj.get(req)
if not req_val:
raise DDSArgumentError(message=error_message)

return req_val


# Cannot have type hint for return due to models.Project giving circular import
def verify_project_access(project) -> None:
"""Verify that current authenticated user has access to project."""
if project not in auth.current_user().projects:
raise AccessDeniedError(
message="Project access denied.",
username=auth.current_user().username,
project=project.public_id,
)


def verify_cli_version(version_cli: str = None) -> None:
"""Verify that the CLI version in header is compatible with the web version."""
# Verify that version is specified
if not version_cli:
raise VersionMismatchError(message="No version found in request, cannot proceed.")
flask.current_app.logger.info(f"CLI VERSION: {version_cli}")

# Split version string up into major, middle, minor
version_cli_parts = version_cli.split(".")
version_correct_parts = __version__.split(".")

# The versions must have the same lengths
if len(version_cli_parts) != len(version_correct_parts):
raise VersionMismatchError(message="Incompatible version lengths.")

# Verify that major versions match
if version_cli_parts[0] != version_correct_parts[0]:
raise VersionMismatchError


def contains_uppercase(indata):
"""Verify that string contains at least one upper case letter."""
Expand Down
2 changes: 1 addition & 1 deletion dds_web/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.8"
__version__ = "1.1.0"
13 changes: 7 additions & 6 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import flask
import flask_login
from requests_mock.mocker import Mocker
from dds_web.version import __version__
from typing import Dict

# Copied from dds_cli __init__.py:

Expand Down Expand Up @@ -39,7 +41,7 @@
"delete_me_unitadmin": "delete_me_unitadmin:password",
}

DEFAULT_HEADER = {"X-CLI-Version": "0.0.0"}
DEFAULT_HEADER = {"X-CLI-Version": __version__}

###############################################################################
# CLASSES ########################################################### CLASSES #
Expand All @@ -65,7 +67,7 @@ def post_headers(self):
return {
"Authorization": f"Basic {self.basic()}",
"Cache-Control": "no-cache",
"X-CLI-Version": "0.0.0",
**DEFAULT_HEADER,
}

def fetch_hotp(self):
Expand All @@ -74,7 +76,7 @@ def fetch_hotp(self):

def partial_token(self, client):
"""Return a partial token that can be used to get a full token."""
headers: Dict = {"Cache-Control": "no-cache", "X-CLI-Version": "0.0.0"}
headers: Dict = {"Cache-Control": "no-cache", **DEFAULT_HEADER}
response = client.get(DDSEndpoint.ENCRYPTED_TOKEN, headers=headers, auth=(self.as_tuple()))

# Get response from api
Expand All @@ -86,9 +88,7 @@ def partial_token(self, client):
return headers

def token(self, client):
pypi_api_url: str = "https://pypi.python.org/pypi/dds-cli/json"
with Mocker() as mock:
mock.get(pypi_api_url, status_code=200, json={"info": {"version": "0.0.0"}})
temp_token = self.partial_token(client)

hotp_token = self.fetch_hotp()
Expand All @@ -105,7 +105,7 @@ def token(self, client):
return {
"Authorization": f"Bearer {token}",
"Cache-Control": "no-cache",
"X-CLI-Version": "0.0.0",
**DEFAULT_HEADER,
}
else:
raise ddserr.JwtTokenGenerationError()
Expand Down Expand Up @@ -219,5 +219,6 @@ class DDSEndpoint:
LIST_UNITS_ALL = BASE_ENDPOINT + "/unit/info/all"
MOTD = BASE_ENDPOINT + "/motd"
USER_FIND = BASE_ENDPOINT + "/user/find"
TOTP_DEACTIVATE = BASE_ENDPOINT + "/user/totp/deactivate"

TIMEOUT = 5
Loading

0 comments on commit 5a0cbf9

Please sign in to comment.