From dc2bd412e97096386990cd3416c7ee309a395fac Mon Sep 17 00:00:00 2001 From: mdtro <20070360+mdtro@users.noreply.github.com> Date: Wed, 7 Feb 2024 10:12:59 -0600 Subject: [PATCH 1/4] wip: check verified emails on invite get --- .../api/endpoints/accept_organization_invite.py | 17 +++++++++++++++++ src/sentry/api/invite_helper.py | 2 ++ 2 files changed, 19 insertions(+) diff --git a/src/sentry/api/endpoints/accept_organization_invite.py b/src/sentry/api/endpoints/accept_organization_invite.py index fc836c2f1f8930..ccdfbdfc44c81f 100644 --- a/src/sentry/api/endpoints/accept_organization_invite.py +++ b/src/sentry/api/endpoints/accept_organization_invite.py @@ -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 @@ -111,6 +112,7 @@ class AcceptOrganizationInvite(Endpoint): "POST": ApiPublishStatus.UNKNOWN, } # Disable authentication and permission requirements. + authentication_classes = (SessionAuthentication,) permission_classes = () @staticmethod @@ -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 @@ -230,6 +246,7 @@ def post( user_id=request.user.id, request=request, ) + if invite_context is None: return self.respond_invalid() diff --git a/src/sentry/api/invite_helper.py b/src/sentry/api/invite_helper.py index 988fa5bd95b81f..4ce375a8993f07 100644 --- a/src/sentry/api/invite_helper.py +++ b/src/sentry/api/invite_helper.py @@ -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. @@ -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") From 1ecdd52bb691c3a5f543eda4e57dddb1ac155793 Mon Sep 17 00:00:00 2001 From: mdtro <20070360+mdtro@users.noreply.github.com> Date: Tue, 12 Mar 2024 10:13:53 +0100 Subject: [PATCH 2/4] check verified emails when accepting invite --- .../endpoints/accept_organization_invite.py | 11 +++ .../test_accept_organization_invite.py | 77 ++++++++++++++++--- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/sentry/api/endpoints/accept_organization_invite.py b/src/sentry/api/endpoints/accept_organization_invite.py index ccdfbdfc44c81f..2cb274eb0c73a7 100644 --- a/src/sentry/api/endpoints/accept_organization_invite.py +++ b/src/sentry/api/endpoints/accept_organization_invite.py @@ -262,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() diff --git a/tests/sentry/api/endpoints/test_accept_organization_invite.py b/tests/sentry/api/endpoints/test_accept_organization_invite.py index fa5505dafea97e..e50e448c0d1c18 100644 --- a/tests/sentry/api/endpoints/test_accept_organization_invite.py +++ b/tests/sentry/api/endpoints/test_accept_organization_invite.py @@ -1,4 +1,5 @@ from datetime import timedelta +from uuid import uuid4 from django.conf import settings from django.db import router @@ -17,6 +18,7 @@ from sentry.models.outbox import outbox_context from sentry.silo.base import SiloMode from sentry.silo.safety import unguarded_write +from sentry.models.useremail import UserEmail from sentry.testutils.cases import TestCase from sentry.testutils.factories import Factories from sentry.testutils.helpers.options import override_options @@ -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) @@ -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]): @@ -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( @@ -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) @@ -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) @@ -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() @@ -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]) @@ -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, @@ -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) From 1f809c315f382cbe699ba1c61acce404ceebbd46 Mon Sep 17 00:00:00 2001 From: mdtro <20070360+mdtro@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:47:03 -0500 Subject: [PATCH 3/4] fix invite tests --- .../api/endpoints/test_user_authenticator_enroll.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/sentry/api/endpoints/test_user_authenticator_enroll.py b/tests/sentry/api/endpoints/test_user_authenticator_enroll.py index f6690655b293a5..13b6fc069877ba 100644 --- a/tests/sentry/api/endpoints/test_user_authenticator_enroll.py +++ b/tests/sentry/api/endpoints/test_user_authenticator_enroll.py @@ -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, @@ -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): @@ -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" @@ -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) From e1a60ad3a9a28d52519706f3b82791860027327d Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Mon, 29 Apr 2024 21:31:49 +0000 Subject: [PATCH 4/4] :hammer_and_wrench: apply pre-commit fixes --- tests/sentry/api/endpoints/test_accept_organization_invite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sentry/api/endpoints/test_accept_organization_invite.py b/tests/sentry/api/endpoints/test_accept_organization_invite.py index e50e448c0d1c18..2f1a52d80b2a5b 100644 --- a/tests/sentry/api/endpoints/test_accept_organization_invite.py +++ b/tests/sentry/api/endpoints/test_accept_organization_invite.py @@ -16,9 +16,9 @@ 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.models.useremail import UserEmail from sentry.testutils.cases import TestCase from sentry.testutils.factories import Factories from sentry.testutils.helpers.options import override_options