browsertrix/backend/btrixcloud/invites.py
Ilya Kreymer 1c42e21b8a
Refactor Invites and Registration, Flatten Per-User Invites (#1902)
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)
2024-07-02 15:13:27 -07:00

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()