diff --git a/mwdb/app.py b/mwdb/app.py index 09a852399..592946914 100755 --- a/mwdb/app.py +++ b/mwdb/app.py @@ -46,7 +46,13 @@ FileItemResource, FileResource, ) -from mwdb.resources.group import GroupListResource, GroupMemberResource, GroupResource +from mwdb.resources.group import ( + GroupInviteResource, + GroupJoinResource, + GroupListResource, + GroupMemberResource, + GroupResource, +) from mwdb.resources.karton import KartonAnalysisResource, KartonObjectResource from mwdb.resources.metakey import ( MetakeyDefinitionManageResource, @@ -353,6 +359,8 @@ def require_auth(): api.add_resource(GroupListResource, "/group") api.add_resource(GroupResource, "/group/") api.add_resource(GroupMemberResource, "/group//member/") +api.add_resource(GroupInviteResource, "/group//invite/") +api.add_resource(GroupJoinResource, "/group/join") # OAuth endpoints if app_config.mwdb.enable_oidc: diff --git a/mwdb/core/auth.py b/mwdb/core/auth.py index ab3f89ac2..16485feba 100644 --- a/mwdb/core/auth.py +++ b/mwdb/core/auth.py @@ -12,6 +12,7 @@ class AuthScope(Enum): api_key = "api_key" set_password = "set_password" download_file = "download_file" + group_invite = "group_invite" def generate_token(fields, scope, expiration=None): diff --git a/mwdb/core/config.py b/mwdb/core/config.py index 98ccdb8ee..b4398ff9d 100644 --- a/mwdb/core/config.py +++ b/mwdb/core/config.py @@ -135,6 +135,10 @@ class MWDBConfig(Config): log_only_slow_sql = key(cast=intbool, required=False, default=False) use_x_forwarded_for = key(cast=intbool, required=False, default=False) + group_invite_expiration_time = key( + cast=int, required=False, default=7 * 24 * 3600 + ) # one week + @section("karton") class KartonConfig(Config): diff --git a/mwdb/model/user.py b/mwdb/model/user.py index e4efc00fc..6bd47bdeb 100644 --- a/mwdb/model/user.py +++ b/mwdb/model/user.py @@ -168,7 +168,7 @@ def _generate_token(self, user_fields, scope, expiration, **extra_fields): return token @staticmethod - def _verify_token(token, fields, scope) -> Optional[Tuple["User", Optional[str]]]: + def _verify_token(token, fields, scope) -> Optional[Tuple["User", dict]]: data = verify_token(token, scope) if data is None: return None @@ -184,7 +184,7 @@ def _verify_token(token, fields, scope) -> Optional[Tuple["User", Optional[str]] if data[field] != getattr(user_obj, field): return None - return user_obj, data.get("provider") + return user_obj, data def generate_session_token(self, provider=None): return self._generate_token( @@ -201,22 +201,52 @@ def generate_set_password_token(self): expiration=14 * 24 * 3600, ) + def generate_group_invite_token(self, group_id, expiration): + return self._generate_token( + user_fields=["identity_ver"], + scope=AuthScope.group_invite, + expiration=expiration, + group_id=group_id, + ) + @staticmethod - def verify_session_token(token) -> Optional[Tuple["User", Optional[str]]]: - return User._verify_token( + def verify_group_invite_token(token: str) -> Optional[Tuple["User", str]]: + result = User._verify_token( + token, + ["identity_ver"], + scope=AuthScope.group_invite, + ) + if result is None: + return None + else: + user, data = result + return user, data["group_id"] + + @staticmethod + def verify_session_token(token: str) -> Optional[Tuple["User", Optional[str]]]: + result = User._verify_token( token, ["password_ver", "identity_ver"], scope=AuthScope.session, ) + if result is None: + return None + else: + user, data = result + return user, data.get("provider") @staticmethod - def verify_set_password_token(token) -> Optional["User"]: + def verify_set_password_token(token: str) -> Optional["User"]: result = User._verify_token( token, ["password_ver"], scope=AuthScope.set_password, ) - return None if result is None else result[0] + if result is None: + return None + else: + user, _ = result + return user @staticmethod def verify_legacy_token(token): diff --git a/mwdb/resources/group.py b/mwdb/resources/group.py index 4f2fbb61a..cf2ee29b1 100644 --- a/mwdb/resources/group.py +++ b/mwdb/resources/group.py @@ -2,14 +2,18 @@ from flask_restful import Resource from sqlalchemy import exists from sqlalchemy.orm import joinedload -from werkzeug.exceptions import Conflict, Forbidden, NotFound +from werkzeug.exceptions import Conflict, Forbidden, InternalServerError, NotFound from mwdb.core.capabilities import Capabilities +from mwdb.core.config import app_config +from mwdb.core.mail import MailError, send_email_notification from mwdb.core.plugins import hooks from mwdb.core.rate_limit import rate_limited_resource from mwdb.model import Group, Member, User, db from mwdb.schema.group import ( GroupCreateRequestSchema, + GroupInvitationLinkResponseSchema, + GroupInviteTokenRequestSchema, GroupItemResponseSchema, GroupListResponseSchema, GroupMemberUpdateRequestSchema, @@ -574,3 +578,231 @@ def delete(self, name, login): ) schema = GroupSuccessResponseSchema() return schema.dump({"name": name}) + + +@rate_limited_resource +class GroupInviteResource(Resource): + @requires_authorization + def post(self, name, invited_user): + """ + --- + summary: Request invitation link + description: | + Creates invitation link and sends an email to the invited user. + + Invitation link works only for secified group and specified user + + Requires `manage_users` capability or group_admin membership. + security: + - bearerAuth: [] + tags: + - group + parameters: + - in: path + name: name + schema: + type: string + description: Group name + - in: path + name: invited_user + schema: + type: string + description: Invited user login + responses: + 200: + description: When link was created successfully + content: + application/json: + schema: GroupInvitationLinkResponseSchema + 400: + description: When request body is invalid + 403: + description: | + When user doesn't have enough permissions, + group is immutable or invited user is pending + 404: + description: When invited user or group doesn't exist + 409: + description: When user is already a member of this group + 503: + description: | + Request canceled due to database statement timeout. + """ + group_obj = (db.session.query(Group).filter(Group.name == name)).first() + + if group_obj is None: + raise NotFound("Group does not exist or you are not its member") + + member_obj = ( + db.session.query(Member) + .filter(Member.group_id == group_obj.id) + .filter(Member.user_id == g.auth_user.id) + ).first() + + if member_obj is None: + raise NotFound("Group does not exist or you are not its member") + + if not member_obj.group_admin: + raise Forbidden("You do not have group_admin role") + + if group_obj.private or group_obj.immutable: + raise Forbidden("You cannot invite users to this group") + + invited_user_obj = ( + db.session.query(User).filter(User.login == invited_user) + ).first() + + if invited_user_obj is None: + raise NotFound("Invited user does not exist") + + if invited_user_obj.pending: + raise Forbidden("Invited user is pending") + + if ( + db.session.query(Member) + .filter(Member.group_id == group_obj.id) + .filter(Member.user_id == invited_user_obj.id) + ).first() is not None: + raise Conflict("Invited user is already a member of this group") + + token = invited_user_obj.generate_group_invite_token( + group_obj.id, app_config.mwdb.group_invite_expiration_time + ) + + try: + send_email_notification( + "group_invitation", + "You have been invited to a new group in MWDB", + invited_user_obj.email, + base_url=app_config.mwdb.base_url, + login=invited_user_obj.login, + group_invite_token=token, + ) + except MailError: + logger.exception("Can't send e-mail notification") + raise InternalServerError( + "SMTP server needed to fulfill this request is" + " not configured or unavailable." + ) + + schema = GroupInvitationLinkResponseSchema() + return schema.dump( + {"link": app_config.mwdb.base_url + "/group/invite?token=" + token} + ) + + +@rate_limited_resource +class GroupJoinResource(Resource): + @requires_authorization + def get(self): + """ + --- + summary: Get information about group from invitation token + description: | + Get information about group from invitation token + + security: + - bearerAuth: [] + parameters: + - in: query + name: token + schema: + type: string + description: token + tags: + - group + responses: + 200: + description: When data was read successfully + content: + application/json: + schema: GroupSuccessResponseSchema + 400: + description: When request body is invalid + 403: + description: When there was a problem with the token + 503: + description: | + Request canceled due to database statement timeout. + """ + args = load_schema(request.args, GroupInviteTokenRequestSchema()) + token_data = User.verify_group_invite_token(args["token"]) + if not token_data: + raise Forbidden( + "Token expired, please re-request invitation to the group administrator" + ) + + invited_user, group_id = token_data + if g.auth_user.id != invited_user.id: + raise Forbidden("This invitation is not for you") + + group_obj = db.session.query(Group).filter(Group.id == group_id).first() + if group_obj is None: + raise NotFound("Group does not exist") + + schema = GroupSuccessResponseSchema() + return schema.dump({"name": group_obj.name}) + + @requires_authorization + def post(selt): + """ + --- + summary: Join group using invitation link + description: | + Join group using link + + security: + - bearerAuth: [] + parameters: + - in: query + name: token + schema: + type: string + description: token + tags: + - group + responses: + 200: + description: When user joined group successfully + content: + application/json: + schema: GroupSuccessResponseSchema + 400: + description: When request body is invalid + 403: + description: When there was a problem with the token + 409: + description: When user is already a member of this group + 503: + description: | + Request canceled due to database statement timeout. + """ + args = load_schema(request.args, GroupInviteTokenRequestSchema()) + token_data = User.verify_group_invite_token(args["token"]) + if not token_data: + raise Forbidden( + "Token expired, please re-request invitation to the group administrator" + ) + + invited_user, group_id = token_data + if g.auth_user.id != invited_user.id: + raise Forbidden("This invitation is not for you") + + member = ( + db.session.query(Member) + .filter(Member.group_id == group_id) + .filter(Member.user_id == g.auth_user.id) + ).first() + + if member is not None: + raise Conflict("You are already member of this group") + + group_obj = db.session.query(Group).filter(Group.id == group_id).first() + if group_obj is None: + raise NotFound("This group does not exist") + + group_obj.add_member(g.auth_user) + db.session.commit() + + schema = GroupSuccessResponseSchema() + return schema.dump({"name": group_obj.name}) diff --git a/mwdb/schema/group.py b/mwdb/schema/group.py index 5c11752fa..0f21d47d6 100644 --- a/mwdb/schema/group.py +++ b/mwdb/schema/group.py @@ -40,6 +40,10 @@ class GroupMemberUpdateRequestSchema(Schema): group_admin = fields.Boolean(required=True) +class GroupInviteTokenRequestSchema(Schema): + token = fields.Str(required=True) + + class GroupBasicResponseSchema(GroupNameSchemaBase): capabilities = fields.List(fields.Str(), required=True, allow_none=False) private = fields.Boolean(required=True) @@ -67,3 +71,7 @@ class GroupListResponseSchema(Schema): class GroupSuccessResponseSchema(GroupNameSchemaBase): pass + + +class GroupInvitationLinkResponseSchema(Schema): + link = fields.Str(required=True, allow_none=False) diff --git a/mwdb/templates/mail/group_invitation.txt b/mwdb/templates/mail/group_invitation.txt new file mode 100644 index 000000000..d30511a66 --- /dev/null +++ b/mwdb/templates/mail/group_invitation.txt @@ -0,0 +1,5 @@ +Hi {login}, + +You have been invited to join a new group. + +To view the invitation click this link: {base_url}/profile/group/join?token={group_invite_token} \ No newline at end of file diff --git a/mwdb/web/src/commons/api/index.tsx b/mwdb/web/src/commons/api/index.tsx index a3d4310cd..828e43f56 100644 --- a/mwdb/web/src/commons/api/index.tsx +++ b/mwdb/web/src/commons/api/index.tsx @@ -424,6 +424,18 @@ function setGroupAdmin( return axios.put(`/group/${name}/member/${member}`, { group_admin }); } +function requestGroupInviteLink(name: string, invited_user: string) { + return axios.post(`/group/${name}/invite/${invited_user}`); +} + +function getInvitationData(token: string) { + return axios.get(`/group/join?token=${token}`); +} + +function acceptGroupInvitation(token: string) { + return axios.post(`/group/join?token=${token}`); +} + function getUsers(): GetUsersResponse { return axios.get("/user", { timeout: undefined }); } @@ -839,6 +851,9 @@ export const api = { getPendingUsers, acceptPendingUser, rejectPendingUser, + requestGroupInviteLink, + getInvitationData, + acceptGroupInvitation, getUsers, getUser, getUserProfile, diff --git a/mwdb/web/src/commons/navigation/AppRoutes.tsx b/mwdb/web/src/commons/navigation/AppRoutes.tsx index ee115cf4a..291717fd4 100644 --- a/mwdb/web/src/commons/navigation/AppRoutes.tsx +++ b/mwdb/web/src/commons/navigation/AppRoutes.tsx @@ -62,6 +62,7 @@ import { UploadConfigView } from "@mwdb-web/components/Upload/Views/UploadConfig import { UploadBlobView } from "@mwdb-web/components/Upload/Views/UploadBlobView"; import { UploadFileView } from "@mwdb-web/components/Upload/Views/UploadFileView"; import { SearchView } from "@mwdb-web/components/Views/SearchView"; +import { GroupJoinView } from "@mwdb-web/components/Views/GroupJoinView"; export function AppRoutes() { return ( @@ -119,6 +120,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } diff --git a/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.tsx b/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.tsx index b5dc4dcee..860f09e1a 100644 --- a/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.tsx +++ b/mwdb/web/src/components/Profile/Views/ProfileGroupMembers.tsx @@ -1,5 +1,6 @@ import { useContext, useEffect, useState } from "react"; import { Navigate, useParams, useOutletContext } from "react-router-dom"; +import { toast } from "react-toastify"; import { isEmpty, isNil } from "lodash"; import { api } from "@mwdb-web/commons/api"; @@ -9,6 +10,47 @@ import { Capability } from "@mwdb-web/types/types"; import { ProfileGroupItems } from "../common/ProfileGroupItems"; import { ProfileOutletContext } from "@mwdb-web/types/context"; import { Group } from "@mwdb-web/types/types"; +import { getErrorMessage } from "@mwdb-web/commons/helpers"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; + +function InviteMemberForm({ groupName }: { groupName: string }) { + const [newMember, setNewMember] = useState(""); + + async function inviteMember(invitedUserLogin: string) { + try { + await api.requestGroupInviteLink(groupName, invitedUserLogin); + toast("Invitation sent", { type: "success" }); + } catch (error) { + toast(getErrorMessage(error), { type: "error" }); + } + } + + return ( +
+
+ { + setNewMember(event.target.value); + }} + /> + +
+
+ ); +} export function ProfileGroupMembers() { const auth = useContext(AuthContext); @@ -72,6 +114,7 @@ export function ProfileGroupMembers() { Group {groupName}{" "} members + diff --git a/mwdb/web/src/components/Views/GroupJoinView.tsx b/mwdb/web/src/components/Views/GroupJoinView.tsx new file mode 100644 index 000000000..b95cf59da --- /dev/null +++ b/mwdb/web/src/components/Views/GroupJoinView.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { api } from "@mwdb-web/commons/api"; +import { ConfirmationModal } from "@mwdb-web/commons/ui"; +import { getErrorMessage } from "@mwdb-web/commons/helpers"; +import { toast } from "react-toastify"; + +export function GroupJoinView() { + const params = useSearchParams()[0]; + const token = params.get("token") ?? ""; + const [groupName, setGroupName] = useState(""); + const navigate = useNavigate(); + + async function getInvitationDetails() { + try { + let response = await api.getInvitationData(token); + setGroupName(response.data.name); + } catch (error) { + toast(getErrorMessage(error), { + type: "error", + }); + navigate("/profile/groups"); + } + } + async function acceptInvitation() { + try { + await api.acceptGroupInvitation(token); + toast("You are now a member", { + type: "success", + }); + navigate("/profile/groups"); + } catch (error) { + toast(getErrorMessage(error), { + type: "error", + }); + } + } + + useEffect(() => { + getInvitationDetails(); + }, []); + + return ( +
+
+ { + navigate("/profile/groups"); + }} + onConfirm={acceptInvitation} + > +
+ ); +} diff --git a/tests/backend/test_auth.py b/tests/backend/test_auth.py index 6375fb42b..303a35e28 100644 --- a/tests/backend/test_auth.py +++ b/tests/backend/test_auth.py @@ -2,7 +2,7 @@ import jwt -from .utils import MwdbTest, ShouldRaise, admin_login, rand_string +from .utils import MwdbTest, ShouldRaise, admin_login, random_name def test_profile_change_invalidate(typical_session, admin_session): @@ -178,3 +178,35 @@ def test_invalid_jwt(admin_session): session.set_auth_token(jwt.encode(missing_sub_jwt, secret_key, algorithm="HS512")) response = session.request("get", "/server") assert not response["is_authenticated"] + + +def test_group_invite(admin_session): + alice_username = random_name() + bob_username = random_name() + charlie_username = random_name() + group_name = random_name() + admin_session.register_user(alice_username, "AliceAlice") + admin_session.register_user(bob_username, "BobBobBob") + admin_session.register_user(charlie_username, "CharlieCharlie") + admin_session.create_group(group_name) + admin_session.add_member(group_name, alice_username) + # set Alice as group_admin + admin_session.update_group_admin(group_name, alice_username, True) + + alice_session = MwdbTest() + bob_session = MwdbTest() + charlie_session = MwdbTest() + alice_session.login_as(alice_username, "AliceAlice") + bob_session.login_as(bob_username, "BobBobBob") + charlie_session.login_as(charlie_username, "CharlieCharlie") + + data = alice_session.request_group_invite_link(group_name, bob_username) + token = data["link"].split("=")[-1] + + with ShouldRaise(403): + charlie_session.join_group_with_invitation_link(token) + + bob_session.join_group_with_invitation_link(token) + + members = admin_session.get_group(group_name)["users"] + assert bob_username in members diff --git a/tests/backend/utils.py b/tests/backend/utils.py index 5863af2f3..f49ceee03 100644 --- a/tests/backend/utils.py +++ b/tests/backend/utils.py @@ -152,6 +152,29 @@ def remove_member(self, name, username): ) res.raise_for_status() + def update_group_admin(self, name: str, username: str, is_admin: bool): + res = self.session.put( + self.mwdb_url + "/group/" + name + "/member/" + username, + json={ + "group_admin": is_admin + } + ) + res.raise_for_status() + + def request_group_invite_link(self, groupname, username): + res = self.session.post( + self.mwdb_url + "/group/" + groupname + "/invite/" + username + ) + res.raise_for_status() + return res.json() + + def join_group_with_invitation_link(self, token): + res = self.session.post( + self.mwdb_url + "/group/join?token=" + token + ) + res.raise_for_status() + return res.json() + def register_user(self, username, password, capabilities=None): capabilities = capabilities or [] self.login()