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

Create group invite links #834

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
10 changes: 9 additions & 1 deletion mwdb/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -353,6 +359,8 @@ def require_auth():
api.add_resource(GroupListResource, "/group")
api.add_resource(GroupResource, "/group/<name>")
api.add_resource(GroupMemberResource, "/group/<name>/member/<login>")
api.add_resource(GroupInviteResource, "/group/<name>/invite/<invited_user>")
api.add_resource(GroupJoinResource, "/group/join")

# OAuth endpoints
if app_config.mwdb.enable_oidc:
Expand Down
1 change: 1 addition & 0 deletions mwdb/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions mwdb/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
42 changes: 36 additions & 6 deletions mwdb/model/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dict is a generic type (dict[KT, VT]). Its key and value types should be parametrized.

data = verify_token(token, scope)
if data is None:
return None
Expand All @@ -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(
Expand All @@ -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:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else after return isn't necessary (unless used for performance reasons in an extremely often called function).

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:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else after return isn't necessary (unless used for performance reasons in an extremely often called function).

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:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else after return isn't necessary (unless used for performance reasons in an extremely often called function).

user, _ = result
return user

@staticmethod
def verify_legacy_token(token):
Expand Down
234 changes: 233 additions & 1 deletion mwdb/resources/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."
)
Copy link

@bswck bswck Nov 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good place for exception chaining.


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})
8 changes: 8 additions & 0 deletions mwdb/schema/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -67,3 +71,7 @@ class GroupListResponseSchema(Schema):

class GroupSuccessResponseSchema(GroupNameSchemaBase):
pass


class GroupInvitationLinkResponseSchema(Schema):
link = fields.Str(required=True, allow_none=False)
5 changes: 5 additions & 0 deletions mwdb/templates/mail/group_invitation.txt
Original file line number Diff line number Diff line change
@@ -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}
Loading