-
Notifications
You must be signed in to change notification settings - Fork 74
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
base: master
Are you sure you want to change the base?
Changes from all commits
7bde325
16cb3b1
3c7a848
cef5389
6f1142e
1cc98fc
87fa852
3532426
ac18f0a
a7bb455
e9493e1
8e9d6e6
7434b1b
1062565
7aa8e8a
6d36a53
9276ad4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
user, _ = result | ||
return user | ||
|
||
@staticmethod | ||
def verify_legacy_token(token): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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." | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}) |
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} |
There was a problem hiding this comment.
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.