Skip to content

security: require verified email to accept invite when logged in #66849

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

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/sentry/api/endpoints/accept_organization_invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.http import HttpRequest
from django.urls import reverse
from rest_framework import status
from rest_framework.authentication import SessionAuthentication
from rest_framework.request import Request
from rest_framework.response import Response

Expand Down Expand Up @@ -111,6 +112,7 @@ class AcceptOrganizationInvite(Endpoint):
"POST": ApiPublishStatus.UNKNOWN,
}
# Disable authentication and permission requirements.
authentication_classes = (SessionAuthentication,)
permission_classes = ()

@staticmethod
Expand Down Expand Up @@ -173,6 +175,20 @@ def get(

response = Response(None)

# if the user is already authenticated, let's make sure
# they have the email that the invite was sent to as a
# verified email on their account
if self.request.user.is_authenticated:
user_verified_emails = {e.email for e in self.request.user.get_verified_emails()}

if organization_member.email not in user_verified_emails:
return Response(
status=403,
data={
"details": "Your account must have a verified email matching the email the invite was sent to."
},
)

# Allow users to register an account when accepting an invite
if not helper.user_authenticated:
request.session["can_register"] = True
Expand Down Expand Up @@ -230,6 +246,7 @@ def post(
user_id=request.user.id,
request=request,
)

if invite_context is None:
return self.respond_invalid()

Expand All @@ -245,6 +262,17 @@ def post(
data={"details": "unable to accept organization invite"},
)
else:
if self.request.user.is_authenticated:
user_verified_emails = {e.email for e in self.request.user.get_verified_emails()}

if invite_context.member.email not in user_verified_emails:
return Response(
status=403,
data={
"details": "Your account must have a verified email matching the email the invite was sent to."
},
)

response = Response(status=status.HTTP_204_NO_CONTENT)

helper.accept_invite()
Expand Down
2 changes: 2 additions & 0 deletions src/sentry/api/invite_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def from_session_or_email(
invite = organization_service.get_invite_by_id(
organization_id=organization_id, email=email, user_id=request.user.id
)

if invite is None:
# Unable to locate the pending organization member. Cannot setup
# the invite helper.
Expand Down Expand Up @@ -114,6 +115,7 @@ def from_session(
organization_id=invite_details.invite_organization_id,
user_id=request.user.id,
)

if invite_context is None:
if logger:
logger.exception("Invalid pending invite cookie")
Expand Down
77 changes: 67 additions & 10 deletions tests/sentry/api/endpoints/test_accept_organization_invite.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import timedelta
from uuid import uuid4

from django.conf import settings
from django.db import router
Expand All @@ -15,6 +16,7 @@
from sentry.models.organizationmember import InviteStatus, OrganizationMember
from sentry.models.organizationmembermapping import OrganizationMemberMapping
from sentry.models.outbox import outbox_context
from sentry.models.useremail import UserEmail
from sentry.silo.base import SiloMode
from sentry.silo.safety import unguarded_write
from sentry.testutils.cases import TestCase
Expand Down Expand Up @@ -125,7 +127,7 @@ def test_not_needs_authentication(self):
self.login_as(self.user)

om = Factories.create_member(
email="newuser@example.com", token="abc", organization=self.organization
email=self.user.email, token="abc", organization=self.organization
)
for path in self._get_paths([om.id, om.token]):
resp = self.client.get(path)
Expand All @@ -140,7 +142,7 @@ def test_user_needs_2fa(self):
self.login_as(self.user)

om = Factories.create_member(
email="newuser@example.com", token="abc", organization=self.organization
email=self.user.email, token="abc", organization=self.organization
)

for path in self._get_paths([om.id, om.token]):
Expand Down Expand Up @@ -175,7 +177,7 @@ def test_multi_region_organizationmember_id(self):

with assume_test_silo_mode_of(OrganizationMember), outbox_context(flush=False):
om = OrganizationMember.objects.create(
email="newuser@example.com", token="abc", organization_id=self.organization.id
email=self.user.email, token="abc", organization_id=self.organization.id
)
with unguarded_write(using=router.db_for_write(OrganizationMemberMapping)):
OrganizationMemberMapping.objects.create(
Expand Down Expand Up @@ -222,7 +224,7 @@ def test_user_has_2fa(self):
self.login_as(self.user)

om = Factories.create_member(
email="newuser@example.com", token="abc", organization=self.organization
email=self.user.email, token="abc", organization=self.organization
)
for path in self._get_paths([om.id, om.token]):
resp = self.client.get(path)
Expand All @@ -237,7 +239,7 @@ def test_user_can_use_sso(self):
self.login_as(self.user)

om = Factories.create_member(
email="newuser@example.com", token="abc", organization=self.organization
email=self.user.email, token="abc", organization=self.organization
)
for path in self._get_paths([om.id, om.token]):
resp = self.client.get(path)
Expand Down Expand Up @@ -319,6 +321,61 @@ def test_cannot_accept_unapproved_invite(self):
assert om.is_pending
assert om.token

def test_cannot_accept_without_matching_verified_email(self):
newuser = self.create_user()

newuser_email = UserEmail.objects.get(user=newuser, email=newuser.email)
newuser_email.is_verified = False
newuser_email.save()

self.login_as(newuser)

assert not newuser_email.is_verified

om = Factories.create_member(
email=newuser.email,
role="member",
token="abc",
organization=self.organization,
invite_status=InviteStatus.APPROVED.value,
)

for path in self._get_paths([om.id, om.token]):
resp = self.client.post(path)
assert resp.status_code == 403, resp.content

with assume_test_silo_mode_of(OrganizationMember):
om = OrganizationMember.objects.get(id=om.id)

assert om.is_pending
assert om.token

def test_can_accept_with_matching_verified_email(self):
urls = self._get_urls()

for url in urls:
newuser = self.create_user() # implicitly verifies the email
self.login_as(newuser)

om = Factories.create_member(
email=newuser.email,
role="member",
token=uuid4().hex,
organization=self.organization,
invite_status=InviteStatus.APPROVED.value,
)

path = self._get_path(url, [om.id, om.token])
resp = self.client.post(path)

assert resp.status_code == 204, resp.content

with assume_test_silo_mode_of(OrganizationMember):
om = OrganizationMember.objects.get(id=om.id)

assert not om.is_pending
assert not om.token

def test_member_already_exists(self):
urls = self._get_urls()

Expand All @@ -329,7 +386,7 @@ def test_member_already_exists(self):
om = Factories.create_member(
email=user.email,
role="member",
token="abc",
token=uuid4().hex,
organization=self.organization,
)
path = self._get_path(url, [om.id, om.token])
Expand Down Expand Up @@ -367,7 +424,7 @@ def test_can_accept_when_user_has_2fa(self):
self.login_as(user)

om = Factories.create_member(
email="newuser" + str(i) + "@example.com",
email=user.email,
role="member",
token="abc",
organization=self.organization,
Expand Down Expand Up @@ -420,19 +477,19 @@ def test_2fa_cookie_deleted_after_accept(self):
self.login_as(user)

om = Factories.create_member(
email="newuser" + str(i) + "@example.com",
email=user.email,
role="member",
token="abc",
organization=self.organization,
)
path = self._get_path(url, [om.id, om.token])
resp = self.client.get(path)
assert resp.status_code == 200
assert resp.status_code == 200, resp.content
self._assert_pending_invite_details_in_session(om)

self._enroll_user_in_2fa(user)
resp = self.client.post(path)
assert resp.status_code == 204
assert resp.status_code == 204, resp.content

self._assert_pending_invite_details_not_in_session(resp)

Expand Down
8 changes: 4 additions & 4 deletions tests/sentry/api/endpoints/test_user_authenticator_enroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ def create_existing_om(self):
def get_om_and_init_invite(self):
with assume_test_silo_mode(SiloMode.REGION), outbox_runner():
om = OrganizationMember.objects.create(
email="newuser@example.com",
email=self.user.email,
role="member",
token="abc",
organization=self.organization,
Expand Down Expand Up @@ -393,7 +393,7 @@ def test_cannot_accept_invite_pending_invite__2fa_required(self):
with assume_test_silo_mode(SiloMode.REGION):
om = OrganizationMember.objects.get(id=om.id)
assert om.user_id is None
assert om.email == "newuser@example.com"
assert om.email == "bar@example.com"

@mock.patch("sentry.auth.authenticators.U2fInterface.try_enroll", return_value=True)
def test_accept_pending_invite__u2f_enroll(self, try_enroll):
Expand Down Expand Up @@ -489,7 +489,7 @@ def test_org_member_does_not_exist(self, try_enroll, log):
with assume_test_silo_mode(SiloMode.REGION):
om = OrganizationMember.objects.get(id=om.id)
assert om.user_id is None
assert om.email == "newuser@example.com"
assert om.email == "bar@example.com"

assert log.exception.call_count == 1
assert log.exception.call_args[0][0] == "Invalid pending invite cookie"
Expand All @@ -511,7 +511,7 @@ def test_invalid_token(self, try_enroll, log):
with assume_test_silo_mode(SiloMode.REGION):
om = OrganizationMember.objects.get(id=om.id)
assert om.user_id is None
assert om.email == "newuser@example.com"
assert om.email == "bar@example.com"

@mock.patch("sentry.api.endpoints.user_authenticator_enroll.logger")
@mock.patch("sentry.auth.authenticators.U2fInterface.try_enroll", return_value=True)
Expand Down
Loading