Fixes #1432 Refactors the invite + registration system to be simpler and more consistent with regards to existing user invites. Previously, per-user invites are stored in the user.invites dict instead of in the invites collection, which creates a few issues: - Existing user do not show up in Org Invites list: #1432 - Existing user invites also do not expire, unlike new user invites, creating potential security issue. Instead, existing user invites should be treated like new user invites. This PR moves them into the same collection, adding a `userid` field to InvitePending to match with an existing user. If a user already exists, it will be matched by userid, instead of by email. This allows for user to update their email while still being invited. Note that the email of the invited existing user will not change in the invite email. This is also by design: an admin of one org should not be given any hint that an invited user already has an account, such as by having their email automatically update. For an org admin, the invite to a new or existing user should be indistinguishable. The sha256 of invite token is stored instead of actual token for better security. The registration system has also been refactored with the following changes: - Auto-creation of new orgs for new users has been removed - User.create_user() replaces the old User._create() and just creates the user with additional complex logic around org auto-add - Users are added to org in org add_user_to_org() - Users are added to org through invites with add_user_with_invite() Tests: - Additional tests include verifying that existing and new pending invites appear in the pending invites list - Tests for `/users/invite/<token>?email=` and `/users/me/invite/<token>` endpoints - Deleting pending invites - Additional tests added for user self-registration, including existing user self-registration to default org of existing user (in nightly tests)
298 lines
9.3 KiB
Python
298 lines
9.3 KiB
Python
""" Invite system management """
|
|
|
|
from datetime import datetime
|
|
from typing import Optional, Any
|
|
import os
|
|
import urllib.parse
|
|
import time
|
|
import hashlib
|
|
from uuid import UUID, uuid4
|
|
|
|
from pymongo.errors import AutoReconnect
|
|
import pymongo
|
|
from fastapi import HTTPException
|
|
|
|
from .pagination import DEFAULT_PAGE_SIZE
|
|
from .models import (
|
|
UserRole,
|
|
InvitePending,
|
|
InviteRequest,
|
|
InviteOut,
|
|
User,
|
|
Organization,
|
|
)
|
|
from .users import UserManager
|
|
from .emailsender import EmailSender
|
|
from .utils import is_bool
|
|
|
|
|
|
# ============================================================================
|
|
class InviteOps:
|
|
"""invite users (optionally to an org), send emails and delete invites"""
|
|
|
|
invites: Any
|
|
orgs: Any
|
|
|
|
email: EmailSender
|
|
allow_dupe_invites: bool
|
|
|
|
def __init__(self, mdb, email: EmailSender):
|
|
self.invites = mdb["invites"]
|
|
self.orgs = mdb["organizations"]
|
|
self.email = email
|
|
self.allow_dupe_invites = is_bool(os.environ.get("ALLOW_DUPE_INVITES", "0"))
|
|
|
|
async def init_index(self) -> None:
|
|
"""Create TTL index so that invites auto-expire"""
|
|
while True:
|
|
try:
|
|
# Default to 7 days
|
|
expire_after_seconds = int(
|
|
os.environ.get("INVITE_EXPIRE_SECONDS", "604800")
|
|
)
|
|
await self.invites.create_index(
|
|
"created", expireAfterSeconds=expire_after_seconds
|
|
)
|
|
break
|
|
|
|
# pylint: disable=duplicate-code
|
|
except AutoReconnect:
|
|
print(
|
|
"Database connection unavailable to create index. Will try again in 5 scconds",
|
|
flush=True,
|
|
)
|
|
time.sleep(5)
|
|
|
|
await self.invites.create_index([("oid", pymongo.HASHED)])
|
|
await self.invites.create_index([("tokenHash", pymongo.HASHED)])
|
|
|
|
async def add_new_user_invite(
|
|
self,
|
|
new_user_invite: InvitePending,
|
|
invite_token: UUID,
|
|
org_name: str,
|
|
headers: Optional[dict],
|
|
) -> None:
|
|
"""Add invite for new user"""
|
|
|
|
res = await self.invites.find_one(
|
|
{"email": new_user_invite.email, "oid": new_user_invite.oid}
|
|
)
|
|
if res and not self.allow_dupe_invites:
|
|
raise HTTPException(
|
|
status_code=403, detail="This user has already been invited"
|
|
)
|
|
|
|
# Invitations to a specific org via API must include role, so if it's
|
|
# absent assume this is a general invitation from superadmin.
|
|
if not new_user_invite.role:
|
|
new_user_invite.role = UserRole.OWNER
|
|
|
|
if res:
|
|
await self.invites.delete_one({"_id": res["_id"]})
|
|
|
|
await self.invites.insert_one(new_user_invite.to_dict())
|
|
|
|
self.email.send_user_invite(
|
|
new_user_invite, invite_token, org_name, True, headers
|
|
)
|
|
|
|
# pylint: disable=too-many-arguments
|
|
async def add_existing_user_invite(
|
|
self,
|
|
existing_user_invite: InvitePending,
|
|
invite_token: UUID,
|
|
invitee_user: User,
|
|
user: User,
|
|
org: Organization,
|
|
org_name: str,
|
|
headers: Optional[dict],
|
|
) -> None:
|
|
"""Add existing user invite"""
|
|
|
|
if invitee_user.email == user.email:
|
|
raise HTTPException(status_code=400, detail="Can't invite ourselves!")
|
|
|
|
if org.users.get(str(invitee_user.id)):
|
|
raise HTTPException(
|
|
status_code=400, detail="User already a member of this organization."
|
|
)
|
|
|
|
res = await self.invites.find_one(
|
|
{"userid": invitee_user.id, "oid": existing_user_invite.oid}
|
|
)
|
|
|
|
if res and not self.allow_dupe_invites:
|
|
raise HTTPException(status_code=403, detail="user_already_invited_to_org")
|
|
|
|
existing_user_invite.userid = invitee_user.id
|
|
|
|
await self.invites.insert_one(existing_user_invite.to_dict())
|
|
|
|
self.email.send_user_invite(
|
|
existing_user_invite, invite_token, org_name, False, headers
|
|
)
|
|
|
|
async def get_valid_invite(
|
|
self, invite_token: UUID, email: Optional[str], userid: Optional[UUID] = None
|
|
) -> InvitePending:
|
|
"""Retrieve a valid invite data from db, or throw if invalid"""
|
|
token_hash = get_hash(invite_token)
|
|
invite_data = await self.invites.find_one({"tokenHash": token_hash})
|
|
if not invite_data:
|
|
raise HTTPException(status_code=400, detail="invalid_invite")
|
|
|
|
invite = InvitePending.from_dict(invite_data)
|
|
|
|
if userid and invite.userid and userid != invite.userid:
|
|
raise HTTPException(status_code=400, detail="invalid_invite")
|
|
|
|
if email and invite.email and email != invite.email:
|
|
raise HTTPException(status_code=400, detail="invalid_invite")
|
|
|
|
return invite
|
|
|
|
async def remove_invite(self, invite_token: UUID) -> None:
|
|
"""remove invite from invite list"""
|
|
await self.invites.delete_one({"_id": invite_token})
|
|
|
|
async def remove_invite_by_email(
|
|
self, email: str, oid: Optional[UUID] = None
|
|
) -> Any:
|
|
"""remove invite from invite list by email"""
|
|
query: dict[str, object] = {"email": email}
|
|
if oid:
|
|
query["oid"] = oid
|
|
# Use delete_many rather than delete_one to clean up any duplicate
|
|
# invites as well.
|
|
return await self.invites.delete_many(query)
|
|
|
|
# pylint: disable=too-many-arguments
|
|
async def invite_user(
|
|
self,
|
|
invite: InviteRequest,
|
|
user: User,
|
|
user_manager: UserManager,
|
|
org: Organization,
|
|
headers: Optional[dict] = None,
|
|
) -> tuple[bool, UUID]:
|
|
"""Invite user to org (if not specified, to default org).
|
|
|
|
:returns: is_new_user (bool), invite token (UUID)
|
|
"""
|
|
org_name: str
|
|
|
|
if org:
|
|
oid = org.id
|
|
org_name = org.name if str(org.name) != str(org.id) else ""
|
|
else:
|
|
default_org = await self.orgs.find_one({"default": True})
|
|
oid = default_org["_id"]
|
|
org_name = default_org["name"]
|
|
|
|
invite_token = uuid4()
|
|
|
|
invite_pending = InvitePending(
|
|
id=uuid4(),
|
|
oid=oid,
|
|
created=datetime.utcnow(),
|
|
role=invite.role if hasattr(invite, "role") else None,
|
|
# URL decode email address just in case
|
|
email=urllib.parse.unquote(invite.email),
|
|
inviterEmail=user.email,
|
|
fromSuperuser=user.is_superuser,
|
|
tokenHash=get_hash(invite_token),
|
|
)
|
|
|
|
# user being invited
|
|
invitee_user = await user_manager.get_by_email(invite.email)
|
|
|
|
if invitee_user:
|
|
await self.add_existing_user_invite(
|
|
invite_pending,
|
|
invite_token,
|
|
invitee_user,
|
|
user,
|
|
org,
|
|
org_name,
|
|
headers,
|
|
)
|
|
return False, invite_token
|
|
|
|
await self.add_new_user_invite(
|
|
invite_pending,
|
|
invite_token,
|
|
org_name,
|
|
headers,
|
|
)
|
|
return True, invite_token
|
|
|
|
async def get_pending_invites(
|
|
self,
|
|
users: UserManager,
|
|
org: Optional[Organization] = None,
|
|
page_size: int = DEFAULT_PAGE_SIZE,
|
|
page: int = 1,
|
|
) -> tuple[list[InviteOut], int]:
|
|
"""return list of pending invites."""
|
|
# Zero-index page for query
|
|
page = page - 1
|
|
skip = page_size * page
|
|
|
|
match_query = {}
|
|
if org:
|
|
match_query["oid"] = org.id
|
|
|
|
total = await self.invites.count_documents(match_query)
|
|
|
|
cursor = self.invites.find(match_query, skip=skip, limit=page_size)
|
|
results = await cursor.to_list(length=page_size)
|
|
invites = [InvitePending.from_dict(res) for res in results]
|
|
invite_outs = [await self.get_invite_out(invite, users) for invite in invites]
|
|
|
|
return invite_outs, total
|
|
|
|
async def get_invite_out(
|
|
self, invite: InvitePending, users: UserManager, include_first_org_admin=False
|
|
) -> InviteOut:
|
|
"""format an InvitePending to return via api, resolve name of inviter"""
|
|
inviter = await users.get_by_email(invite.inviterEmail)
|
|
if not inviter:
|
|
raise HTTPException(status_code=400, detail="invalid_invite")
|
|
|
|
invite_out = InviteOut(
|
|
created=invite.created,
|
|
inviterEmail=invite.inviterEmail,
|
|
inviterName=inviter.name,
|
|
oid=invite.oid,
|
|
role=invite.role,
|
|
email=invite.email,
|
|
userid=invite.userid,
|
|
)
|
|
|
|
if not invite.oid:
|
|
return invite_out
|
|
|
|
org = await users.org_ops.get_org_for_user_by_id(invite.oid, inviter)
|
|
invite_out.orgName = org.name
|
|
invite_out.orgSlug = org.slug
|
|
|
|
if include_first_org_admin:
|
|
invite_out.firstOrgOwner = True
|
|
for role in org.users.values():
|
|
if role == UserRole.OWNER:
|
|
invite_out.firstOrgOwner = False
|
|
break
|
|
|
|
return invite_out
|
|
|
|
|
|
def init_invites(mdb, email: EmailSender) -> InviteOps:
|
|
"""init InviteOps"""
|
|
return InviteOps(mdb, email)
|
|
|
|
|
|
def get_hash(token: UUID) -> str:
|
|
"""get hash for token"""
|
|
return hashlib.sha256(str(token).encode("utf-8")).hexdigest()
|