diff --git a/lifemonitor/api/models/workflows.py b/lifemonitor/api/models/workflows.py index a27b189ac..7dc723088 100644 --- a/lifemonitor/api/models/workflows.py +++ b/lifemonitor/api/models/workflows.py @@ -22,12 +22,11 @@ import logging from typing import List, Optional, Set, Union +from uuid import UUID from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import aliased -from sqlalchemy.orm.collections import (MappedCollection, - attribute_mapped_collection, - collection) +from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.sql.expression import true @@ -183,22 +182,6 @@ def get_hosted_workflows_by_uri(cls, hosting_service: HostingService, uri: str, return query.all() -class WorkflowVersionCollection(MappedCollection): - - def __init__(self) -> None: - super().__init__(lambda wv: wv.workflow.uuid) - - @collection.internally_instrumented - def __setitem__(self, key, value, _sa_initiator=None): - current_value = self.get(key, set()) - current_value.add(value) - super(WorkflowVersionCollection, self).__setitem__(key, current_value, _sa_initiator) - - @collection.internally_instrumented - def __delitem__(self, key, _sa_initiator=None): - super(WorkflowVersionCollection, self).__delitem__(key, _sa_initiator) - - class WorkflowVersion(ROCrate): id = db.Column(db.Integer, db.ForeignKey(ROCrate.id), primary_key=True) submitter_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=True) @@ -211,8 +194,7 @@ class WorkflowVersion(ROCrate): test_suites = db.relationship("TestSuite", back_populates="workflow_version", cascade="all, delete") submitter = db.relationship("User", uselist=False, - backref=db.backref("workflows", cascade="all, delete-orphan", - collection_class=WorkflowVersionCollection)) + backref=db.backref("workflow_versions", cascade="all, delete-orphan")) __mapper_args__ = { 'polymorphic_identity': 'workflow_version' @@ -439,3 +421,23 @@ def get_hosted_workflow_versions_by_uri(cls, hosting_service: HostingService, ur .join(WorkflowVersion, WorkflowVersion.hosting_service_id == hosting_service.id)\ .filter(HostingService.uuid == lm_utils.uuid_param(hosting_service.uuid))\ .filter(WorkflowVersion.uri == uri).all() + + +def __get_user_workflows_map__(user: User) -> dict[UUID, Set[WorkflowVersion]]: + ''' + utility function to get the workflows of a user from the list of workflow versions + submitted by the user + ''' + workflows = {} + for v in user.workflow_versions: + w_set = workflows.get(v.workflow.uuid, None) + if w_set is None: + workflows[v.workflow.uuid] = {v} + else: + w_set.add(v) + + return workflows + + +# augmentg the User class with the "workflow" property +User.workflows = property(lambda self: __get_user_workflows_map__(self)) diff --git a/lifemonitor/auth/controllers.py b/lifemonitor/auth/controllers.py index 45de399af..73862da6d 100644 --- a/lifemonitor/auth/controllers.py +++ b/lifemonitor/auth/controllers.py @@ -40,7 +40,7 @@ from .forms import (EmailForm, LoginForm, NotificationsForm, Oauth2ClientForm, RegisterForm, SetPasswordForm) from .models import db -from .oauth2.client.services import (get_current_user_identity, get_providers, +from .oauth2.client.services import (get_current_user_identity, get_providers, merge_users, save_current_user_identity) from .oauth2.server.services import server from .services import (authorized, current_registry, current_user, @@ -515,29 +515,46 @@ def disable_registry_sync(): @blueprint.route("/merge", methods=("GET", "POST")) @login_required def merge(): + # get the username and provider from the request username = request.args.get("username") provider = request.args.get("provider") - flash(f"Your {provider} identity is already linked to the username " - f"{username} and cannot be merged to {current_user.username}", - category="warning") - return redirect(url_for('auth.profile')) - # form = LoginForm(data={ - # "username": username, - # "provider": provider}) - # if form.validate_on_submit(): - # user = form.get_user() - # if user: - # if user != current_user: - # merge_users(current_user, user, request.args.get("provider")) - # flash( - # "User {username} has been merged into your account".format( - # username=user.username - # ) - # ) - # return redirect(url_for("auth.index")) - # else: - # form.username.errors.append("Cannot merge with yourself") - # return render_template("auth/merge.j2", form=form) + + # Uncomment to disable the merge feature + # flash(f"Your {p>rovider} identity is already linked to the username " + # f"{username} and cannot be merged to {current_user.username}", + # category="warning") + # return redirect(url_for('auth.profile')) + + # Check the authenticity of the identity before merging + form = LoginForm(data={ + "username": username, + "provider": provider}) + if form.validate_on_submit(): + user = form.get_user() + if user: + # check if the user is the same as the current user + if user == current_user: + flash("Cannot merge with yourself", category="warning") + form.username.errors.append("Cannot merge with yourself") + else: + # merge the users + resulting_user = merge_users(current_user, user, request.args.get("provider")) + logger.debug("User obtained by the merging process: %r", resulting_user) + logout_user() + # login the resulting user + login_user(resulting_user) + # redirect to the profile page with a flash message + flash( + "User {username} has been merged into your account".format( + username=resulting_user.username + ), category="success" + ) + return profile() + # render the merge page + return render_template("auth/merge.j2", form=form, identity={ + "username": username, + "provider": provider + }) @blueprint.route("/create_apikey", methods=("POST",)) diff --git a/lifemonitor/auth/forms.py b/lifemonitor/auth/forms.py index 9a819526f..a59d8053d 100644 --- a/lifemonitor/auth/forms.py +++ b/lifemonitor/auth/forms.py @@ -59,7 +59,10 @@ def get_user(self): if not user: self.username.errors.append("Username not found") return None - if not user.verify_password(self.password.data): + if not user.has_password: + self.password.errors.append("The user has no password set") + return None + if not self.password.data or not user.verify_password(self.password.data): self.password.errors.append("Invalid password") return None return user diff --git a/lifemonitor/auth/models.py b/lifemonitor/auth/models.py index ec4283812..a3a2d28c7 100644 --- a/lifemonitor/auth/models.py +++ b/lifemonitor/auth/models.py @@ -105,7 +105,7 @@ def get_authorization(self, resource: Resource): @property def current_identity(self): from .services import current_registry, current_user - if not current_user.is_anonymous: + if not current_user.is_anonymous and current_user.id == self.id: return self.oauth_identity if current_registry: for p, i in self.oauth_identity.items(): @@ -130,6 +130,8 @@ def has_password(self): return bool(self.password_hash) def verify_password(self, password): + if not self.password_hash: + return False return check_password_hash(self.password_hash, password) def _generate_random_code(self, chars=string.ascii_uppercase + string.digits): diff --git a/lifemonitor/auth/oauth2/client/services.py b/lifemonitor/auth/oauth2/client/services.py index 7eaa66aeb..bc0869c90 100644 --- a/lifemonitor/auth/oauth2/client/services.py +++ b/lifemonitor/auth/oauth2/client/services.py @@ -23,6 +23,7 @@ import logging from flask import current_app, session + from lifemonitor import exceptions from lifemonitor.db import db, db_initialized @@ -78,21 +79,86 @@ def config_oauth2_registry(app, providers=None): logger.debug("OAuth2 registry configured!") -def merge_users(merge_from: User, merge_into: User, provider: str): +def merge_users(merge_from: User, merge_into: User, provider: str = None): assert merge_into != merge_from - logger.debug("Trying to merge %r, %r, %r", merge_into, merge_from, provider) - for identity in list(merge_from.oauth_identity.values()): - identity.user = merge_into - db.session.add(identity) - # TODO: Move all oauth clients to the new user - for client in list(merge_from.clients): - client.user = merge_into - db.session.add(client) - # TODO: Check for other links to move to the new user - # e.g., tokens, workflows, tests, .... - db.session.delete(merge_from) - db.session.commit() - return merge_into + try: + # start a new transaction + with db.session.no_autoflush: + logger.debug("Trying to merge user %r into %r (provider: %r)", merge_from, merge_into, provider) + # make a copy of the workflow versions submitted by the "merge_from" user + workflow_versions = list(merge_from.workflow_versions) + # update the submitter of the workflow versions + for v in workflow_versions: + v.submitter = merge_into + db.session.add(v) + # update suites submitted by the "merge_from" user + for s in v.test_suites: + if s.submitter == merge_from: + s.submitter = merge_into + db.session.add(s) + # update test instances submitted by the "merge_from" user + for i in s.test_instances: + if i.submitter == merge_from: + i.submitter = merge_into + db.session.add(i) + + # update all the remaining permissions + for permission in list(merge_from.permissions): + permission.user = merge_into + db.session.add(permission) + + # move all the authorizations granted to the "merge_from" user to the "merge_into" user + for auth in list(merge_from.authorizations): + auth.user = merge_into + db.session.add(auth) + + # move all the notification of the user "merge_from" to the user "merge_into" + merge_into_notification_ids = [un.notification.id for un in merge_into.notifications] + for user_notification in list(merge_from.notifications): + if user_notification.notification.id not in merge_into_notification_ids: + user_notification.user = merge_into + db.session.add(user_notification) + + # move all the subscriptions of the user "merge_from" to the user "merge_into" + for subscription in list(merge_from.subscriptions): + subscription.user = merge_into + db.session.add(subscription) + + # move all the api keys of the user "merge_from" to the user "merge_into" + for api_key in list(merge_from.api_keys): + api_key.user = merge_into + db.session.add(api_key) + + # move all the oauth identities of the user "merge_from" to the user "merge_into" + for identity in list(merge_from.oauth_identity): + identity.user = merge_into + db.session.add(identity) + + # move all the clients of the user "merge_from" to the user "merge_into" + for client in list(merge_from.clients): + client.user = merge_into + db.session.add(client) + + # remove the "merge_from" user + db.session.delete(merge_from) + + # commit the changes + db.session.add(merge_into) + db.session.commit() + + # remove the "merge_from" user + db.session.delete(merge_from) + + # flush the changes + db.session.flush() + # remove the "merge_from" user + return merge_into + except Exception as e: + logger.error("Unable to merge users: %r", e) + if logger.isEnabledFor(logging.DEBUG): + logger.exception(e) + db.session.rollback() + raise exceptions.LifeMonitorException(title="Unable to merge users") from e def save_current_user_identity(identity: OAuthIdentity): diff --git a/lifemonitor/auth/templates/auth/merge.j2 b/lifemonitor/auth/templates/auth/merge.j2 index 5406e5e55..43708be94 100644 --- a/lifemonitor/auth/templates/auth/merge.j2 +++ b/lifemonitor/auth/templates/auth/merge.j2 @@ -1,15 +1,60 @@ {% extends 'base.j2' %} {% import 'macros.j2' as macros %} + +{% block body_class %} login-page {% endblock %} +{% block body_style %} height: auto; {% endblock %} + {% block body %} -

Merge Account

-

If you want to merge another account into this one, log in to that account here. That account must have a password set.

-
- {{ form.hidden_tag() }} - {{ macros.render_field(form.username) }} - {{ macros.render_field(form.password) }} -
- + {% endblock %} diff --git a/tests/unit/auth/models/test_users.py b/tests/unit/auth/models/test_users.py index 793a23099..a58dcbea2 100644 --- a/tests/unit/auth/models/test_users.py +++ b/tests/unit/auth/models/test_users.py @@ -19,20 +19,24 @@ # SOFTWARE. import logging -from lifemonitor.auth import serializers -from lifemonitor.auth.services import login_registry +from lifemonitor.auth import serializers +from lifemonitor.auth.models import User +from lifemonitor.auth.oauth2.client.services import get_current_user_identity +from lifemonitor.auth.services import login_registry, login_user logger = logging.getLogger() -def test_identity(app_client, user1, client_credentials_registry): +def test_identity_by_registry_credentials(app_client, user1, client_credentials_registry, user2): login_registry(client_credentials_registry) - user = user1['user'] + user: User = user1['user'] logger.debug(user) logger.debug(user.oauth_identity) + logger.debug("User1 current identity: %r", user.current_identity) + assert user.current_identity is not None, "Current identity should not be empty" identity = user.current_identity[client_credentials_registry.name] assert identity, \ @@ -47,6 +51,48 @@ def test_identity(app_client, user1, client_credentials_registry): assert serialization['identities'][client_credentials_registry.name]['provider']['name'] == client_credentials_registry.name, \ "Invalid provider" + # check current_identity + user2_obj = user2['user'] + logger.debug("User2 info: %r", user2) + assert user2_obj.current_identity is not None, "User2 should not be authenticated" + assert user2_obj.current_identity[client_credentials_registry.name].provider == client_credentials_registry.server_credentials, \ + "Unexpected identity provider" + assert user2_obj.current_identity[client_credentials_registry.name].user == user2_obj, \ + "Unexpected identity user" + + +def test_identity_by_user_credentials(app_client, user1, user2): + + user: User = user1['user'] + logger.debug(user) + logger.debug(user.oauth_identity) + + # check current_identity before login + assert user.current_identity is None, "Identity should be empty" + + # login user + login_user(user) + logger.debug("User1 current identity: %r", user.current_identity) + + # check current_identity after login + assert user.current_identity is not None, "Identity should not be empty" + + # check get current user identity + identity = get_current_user_identity() + logger.debug("Current user identity: %r", identity) + + user2_obj = user2['user'] + logger.debug(f"User2 Info: {user2}") + logger.debug(f"User2 Object: {user2_obj}") + + # check oauth identities of user2 + logger.debug(user2_obj.oauth_identity) + assert user2_obj.oauth_identity is not None, "Identity should not be empty" + + # check current_identity of user2 + logger.debug(f"User2 current identity: {user2_obj.current_identity}") + assert user2_obj.current_identity is None, "Identity of user2 should be empty" + def test_identity_unavailable(app_client, user1): user = user1['user']