diff --git a/confirmation/models.py b/confirmation/models.py index 9106e0f6e2..5409e7637f 100644 --- a/confirmation/models.py +++ b/confirmation/models.py @@ -31,7 +31,10 @@ from zerver.models import ( ) if settings.ZILENCER_ENABLED: - from zilencer.models import PreregistrationRemoteServerBillingUser + from zilencer.models import ( + PreregistrationRemoteRealmBillingUser, + PreregistrationRemoteServerBillingUser, + ) class ConfirmationKeyError(Exception): @@ -68,7 +71,9 @@ NoZilencerConfirmationObjT: TypeAlias = Union[ RealmReactivationStatus, ] ZilencerConfirmationObjT: TypeAlias = Union[ - NoZilencerConfirmationObjT, "PreregistrationRemoteServerBillingUser" + NoZilencerConfirmationObjT, + "PreregistrationRemoteServerBillingUser", + "PreregistrationRemoteRealmBillingUser", ] ConfirmationObjT = Union[NoZilencerConfirmationObjT, ZilencerConfirmationObjT] @@ -197,6 +202,7 @@ class Confirmation(models.Model): REALM_CREATION = 7 REALM_REACTIVATION = 8 REMOTE_SERVER_BILLING_LEGACY_LOGIN = 9 + REMOTE_REALM_BILLING_LEGACY_LOGIN = 10 type = models.PositiveSmallIntegerField() class Meta: @@ -237,6 +243,9 @@ if settings.ZILENCER_ENABLED: _properties[Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN] = ConfirmationType( "remote_billing_legacy_server_from_login_confirmation_link" ) + _properties[Confirmation.REMOTE_REALM_BILLING_LEGACY_LOGIN] = ConfirmationType( + "remote_realm_billing_from_login_confirmation_link" + ) def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str: diff --git a/corporate/lib/remote_billing_util.py b/corporate/lib/remote_billing_util.py index ac2107bfd1..8841f15ed0 100644 --- a/corporate/lib/remote_billing_util.py +++ b/corporate/lib/remote_billing_util.py @@ -28,6 +28,7 @@ class RemoteBillingIdentityDict(TypedDict): remote_server_uuid: str remote_realm_uuid: str + remote_billing_user_id: Optional[int] authenticated_at: int uri_scheme: Literal["http://", "https://"] @@ -133,9 +134,8 @@ def get_remote_server_and_user_from_session( request: HttpRequest, server_uuid: str, ) -> Tuple[RemoteZulipServer, Optional[RemoteServerBillingUser]]: - identity_dict = cast( - Optional[LegacyServerIdentityDict], - get_identity_dict_from_session(request, realm_uuid=None, server_uuid=server_uuid), + identity_dict: Optional[LegacyServerIdentityDict] = get_identity_dict_from_session( + request, realm_uuid=None, server_uuid=server_uuid ) if identity_dict is None: diff --git a/corporate/tests/test_remote_billing.py b/corporate/tests/test_remote_billing.py index 53967107d0..5a66b582b9 100644 --- a/corporate/tests/test_remote_billing.py +++ b/corporate/tests/test_remote_billing.py @@ -33,6 +33,10 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): next_page: Optional[str] = None, expect_tos: bool = True, confirm_tos: bool = True, + first_time_login: bool = True, + # This only matters if first_time_login is True, since otherwise + # there's no confirmation link to be clicked: + return_without_clicking_confirmation_link: bool = False, ) -> "TestHttpResponse": now = timezone_now() @@ -45,12 +49,61 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): self.assertEqual(result.status_code, 302) self.assertIn("http://selfhosting.testserver/remote-billing-login/", result["Location"]) - # We've received a redirect to an URL that will grant us an authenticated - # session for remote billing. signed_auth_url = result["Location"] + signed_access_token = signed_auth_url.split("/")[-1] with time_machine.travel(now, tick=False): result = self.client_get(signed_auth_url, subdomain="selfhosting") - # When successful, we see a confirmation page. + + if first_time_login: + self.assertFalse(RemoteRealmBillingUser.objects.filter(user_uuid=user.uuid).exists()) + # When logging in for the first time some extra steps are needed + # to confirm and verify the email address. + self.assertEqual(result.status_code, 200) + self.assert_in_success_response(["Enter your email address"], result) + self.assert_in_success_response([user.realm.host], result) + self.assert_in_success_response( + [f'action="/remote-billing-login/{signed_access_token}/confirm/"'], result + ) + + with time_machine.travel(now, tick=False): + result = self.client_post( + f"/remote-billing-login/{signed_access_token}/confirm/", + {"email": user.delivery_email}, + subdomain="selfhosting", + ) + self.assertEqual(result.status_code, 200) + self.assert_in_success_response( + ["To complete the login process, check your email account", user.delivery_email], + result, + ) + confirmation_url = self.get_confirmation_url_from_outbox( + user.delivery_email, + url_pattern=( + f"{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}" + r"(\S+)>" + ), + email_body_contains="Click the link below to complete the login process", + ) + if return_without_clicking_confirmation_link: + return result + + with time_machine.travel(now, tick=False): + result = self.client_get(confirmation_url, subdomain="selfhosting") + + remote_billing_user = RemoteRealmBillingUser.objects.latest("id") + self.assertEqual(remote_billing_user.user_uuid, user.uuid) + self.assertEqual(remote_billing_user.email, user.delivery_email) + + # Now we should be redirected again to the /remote-billing-login/ endpoint + # with a new signed_access_token. Now that the email has been confirmed, + # and we have a RemoteRealmBillingUser entry, we'll be in the same position + # as the case where first_time_login=False. + self.assertEqual(result.status_code, 302) + self.assertTrue(result["Location"].startswith("/remote-billing-login/")) + result = self.client_get(result["Location"], subdomain="selfhosting") + + # Final confirmation page - just confirm your details, possibly + # agreeing to ToS if needed and an authenticated session will be granted: self.assertEqual(result.status_code, 200) self.assert_in_success_response(["Log in to Zulip server billing"], result) self.assert_in_success_response([user.realm.host], result) @@ -67,6 +120,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): return result # Verify the authed data that should have been stored in the session. + remote_billing_user = RemoteRealmBillingUser.objects.get(user_uuid=user.uuid) identity_dict = RemoteBillingIdentityDict( user=RemoteBillingUserDict( user_email=user.delivery_email, @@ -75,6 +129,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): ), remote_server_uuid=str(self.server.uuid), remote_realm_uuid=str(user.realm.uuid), + remote_billing_user_id=remote_billing_user.id, authenticated_at=datetime_to_timestamp(now), uri_scheme="http://", next_page=next_page, @@ -189,6 +244,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): desdemona, expect_tos=True, confirm_tos=False, + first_time_login=False, ) self.assert_json_error(result, "You must accept the Terms of Service to proceed.") @@ -196,6 +252,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): desdemona, expect_tos=True, confirm_tos=True, + first_time_login=False, ) remote_billing_user.refresh_from_db() self.assertEqual(remote_billing_user.user_uuid, desdemona.uuid) @@ -252,6 +309,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): # ToS has already been confirmed earlier. expect_tos=False, confirm_tos=False, + first_time_login=False, ) self.assertEqual(result["Location"], f"/realm/{realm.uuid!s}/plans/") result = self.client_get(result["Location"], subdomain="selfhosting") @@ -353,6 +411,28 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): ["Upgrade", "Purchase Zulip", "Your subscription will renew automatically."], result ) + @responses.activate + def test_remote_billing_authentication_flow_cant_access_billing_without_finishing_confirmation( + self, + ) -> None: + self.login("desdemona") + desdemona = self.example_user("desdemona") + realm = desdemona.realm + + self.add_mock_response() + + result = self.execute_remote_billing_authentication_flow( + desdemona, + expect_tos=True, + confirm_tos=False, + first_time_login=True, + return_without_clicking_confirmation_link=True, + ) + result = self.client_get(f"/realm/{realm.uuid!s}/billing/", subdomain="selfhosting") + # Access is not allowed. The user doesn't have an IdentityDict in the session, so + # we can't do a nice redirect back to their original server. + self.assertEqual(result.status_code, 401) + class LegacyServerLoginTest(BouncerTestCase): @override diff --git a/corporate/urls.py b/corporate/urls.py index fa18bec828..b927d10868 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -35,7 +35,9 @@ from corporate.views.remote_billing_page import ( remote_billing_legacy_server_confirm_login, remote_billing_legacy_server_from_login_confirmation_link, remote_billing_legacy_server_login, + remote_realm_billing_confirm_email, remote_realm_billing_finalize_login, + remote_realm_billing_from_login_confirmation_link, ) from corporate.views.session import ( start_card_update_stripe_session, @@ -186,6 +188,15 @@ urlpatterns = list(i18n_urlpatterns) urlpatterns += [ path("remote-billing-login/", remote_realm_billing_finalize_login), + path( + "remote-billing-login//confirm/", + remote_realm_billing_confirm_email, + ), + path( + "remote-billing-login/do_confirm/", + remote_realm_billing_from_login_confirmation_link, + name="remote_realm_billing_from_login_confirmation_link", + ), # Remote server billing endpoints. path("realm//plans/", remote_realm_plans_page, name="remote_realm_plans_page"), path( diff --git a/corporate/views/remote_billing_page.py b/corporate/views/remote_billing_page.py index 89ba9a85f4..da15561397 100644 --- a/corporate/views/remote_billing_page.py +++ b/corporate/views/remote_billing_page.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, Literal, Optional, Union, cast from urllib.parse import urlsplit, urlunsplit from django.conf import settings @@ -42,6 +42,7 @@ from zerver.lib.send_email import FromAddress, send_email from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.typed_endpoint import PathOnly, typed_endpoint from zilencer.models import ( + PreregistrationRemoteRealmBillingUser, PreregistrationRemoteServerBillingUser, RemoteRealm, RemoteRealmBillingUser, @@ -86,6 +87,7 @@ def remote_realm_billing_entry( ), remote_server_uuid=str(remote_server.uuid), remote_realm_uuid=str(remote_realm.uuid), + remote_billing_user_id=None, authenticated_at=datetime_to_timestamp(timezone_now()), uri_scheme=uri_scheme, next_page=next_page, @@ -100,14 +102,34 @@ def remote_realm_billing_entry( return json_success(request, data={"billing_access_url": billing_access_url}) +def get_identity_dict_from_signed_access_token( + signed_billing_access_token: str, +) -> RemoteBillingIdentityDict: + try: + identity_dict: RemoteBillingIdentityDict = signing.loads( + signed_billing_access_token, + max_age=REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS, + ) + except signing.SignatureExpired: + raise JsonableError(_("Billing access token expired.")) + except signing.BadSignature: + raise JsonableError(_("Invalid billing access token.")) + return identity_dict + + @self_hosting_management_endpoint @typed_endpoint def remote_realm_billing_finalize_login( request: HttpRequest, *, signed_billing_access_token: PathOnly[str], + full_name: Optional[str] = None, tos_consent: Literal[None, "true"] = None, ) -> HttpResponse: + """ + This is the endpoint accessed via the billing_access_url, generated by + remote_realm_billing_entry entry. + """ if request.method not in ["GET", "POST"]: return HttpResponseNotAllowed(["GET", "POST"]) tos_consent_given = tos_consent == "true" @@ -118,23 +140,19 @@ def remote_realm_billing_finalize_login( <= REMOTE_BILLING_SESSION_VALIDITY_SECONDS ) - try: - identity_dict: RemoteBillingIdentityDict = signing.loads( - signed_billing_access_token, - max_age=REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS, - ) - except signing.SignatureExpired: - raise JsonableError(_("Billing access token expired.")) - except signing.BadSignature: - raise JsonableError(_("Invalid billing access token.")) + identity_dict = get_identity_dict_from_signed_access_token(signed_billing_access_token) - # Now we want to fetch (or create) the RemoteRealmBillingUser object implied + # Now we want to fetch the RemoteRealmBillingUser object implied # by the IdentityDict. We'll use this: # (1) If the user came here via just GET, we want to show them a confirmation # page with the relevant info details before finalizing login. If they wish # to proceed, they'll approve the form, causing a POST, bring us to case (2). # (2) If the user came here via POST, we finalize login, using the info from the # IdentityDict to update the RemoteRealmBillingUser object if needed. + # Finally, if the user is logging in for the first time, we'll need to create + # their account first. This will happen by making them fill out a form to confirm + # their email first. Only after clicking the confirmation link in the email, + # they will have their account created and finally be redirected back here. remote_realm_uuid = identity_dict["remote_realm_uuid"] remote_server_uuid = identity_dict["remote_server_uuid"] try: @@ -148,7 +166,6 @@ def remote_realm_billing_finalize_login( user_dict = identity_dict["user"] user_email = user_dict["user_email"] - user_full_name = user_dict["user_full_name"] user_uuid = user_dict["user_uuid"] assert ( @@ -164,47 +181,71 @@ def remote_realm_billing_finalize_login( remote_user.tos_version.split(".")[0] ) except RemoteRealmBillingUser.DoesNotExist: - # This is the first time this user is logging in, so ToS consent needed. + # This is the first time this user is logging in. + remote_user = None tos_consent_needed = True if request.method == "GET": - context = { - "remote_server_uuid": remote_server_uuid, - "remote_realm_uuid": remote_realm_uuid, - "host": remote_realm.host, - "user_email": user_email, - "user_full_name": user_full_name, - "tos_consent_needed": tos_consent_needed, - "action_url": reverse( - remote_realm_billing_finalize_login, args=(signed_billing_access_token,) - ), - } - return render( - request, - "corporate/remote_realm_billing_finalize_login_confirmation.html", - context=context, - ) + if remote_user is not None: + # Render a template where the user will just confirm their info, + # possibly accept ToS if needed, POST back here and will get fully + # authenticated. + context = { + "remote_server_uuid": remote_server_uuid, + "remote_realm_uuid": remote_realm_uuid, + "host": remote_realm.host, + "user_email": remote_user.email, + "user_full_name": remote_user.full_name, + "tos_consent_needed": tos_consent_needed, + "action_url": reverse( + remote_realm_billing_finalize_login, args=(signed_billing_access_token,) + ), + } + return render( + request, + "corporate/remote_realm_billing_finalize_login_confirmation.html", + context=context, + ) + else: + # This user is logging in for the first time, so we need to create their + # RemoteRealmBillingUser object. Render a template where they'll + # enter their email address - we'll send a verification email to it. + context = { + "email": user_email, + "action_url": reverse( + remote_realm_billing_confirm_email, args=(signed_billing_access_token,) + ), + } + return render( + request, + # TODO: We're re-using a template originally made for the legacy server + # flow. We should rename it and its HTML contents to a more general name. + "corporate/remote_billing_legacy_server_confirm_login_form.html", + context=context, + ) assert request.method == "POST" + if remote_user is None: + # Users logging in for the first time need to be created and follow + # a different path - they should not be POSTing here. It should be impossible + # to get here with a remote_user that is None without tampering with the form + # or manualling crafting a POST request. + raise JsonableError(_("User account doesn't exist yet.")) if tos_consent_needed and not tos_consent_given: # This shouldn't be possible without tampering with the form, so we # don't need a pretty error. raise JsonableError(_("You must accept the Terms of Service to proceed.")) - remote_user, created = RemoteRealmBillingUser.objects.get_or_create( - defaults={"full_name": user_full_name, "email": user_email}, - remote_realm=remote_realm, - user_uuid=user_uuid, - ) - - # The current approach is to just update the email and full_name - # based on the info provided by the remote server during auth. - remote_user.email = user_email - remote_user.full_name = user_full_name + # The current approach is to update the full_name + # based on what the user entered in the login confirmation form. + # Usually they'll presumably just use the name already set for this object. + if full_name is not None: + remote_user.full_name = full_name remote_user.tos_version = settings.TERMS_OF_SERVICE_VERSION - remote_user.save(update_fields=["email", "full_name", "tos_version"]) + remote_user.save(update_fields=["full_name", "tos_version"]) + identity_dict["remote_billing_user_id"] = remote_user.id request.session["remote_billing_identities"] = {} request.session["remote_billing_identities"][ f"remote_realm:{remote_realm_uuid}" @@ -224,6 +265,150 @@ def remote_realm_billing_finalize_login( ) +@self_hosting_management_endpoint +@typed_endpoint +def remote_realm_billing_confirm_email( + request: HttpRequest, + *, + signed_billing_access_token: PathOnly[str], + email: str, +) -> HttpResponse: + """ + Endpoint for users in the RemoteRealm flow that are logging in for the first time + and still have to have their RemoteRealmBillingUser object created. + Takes the POST from the above form asking for their email address + and sends confirmation email to the provided + email address in order to verify. Only the confirmation link will grant + a fully authenticated session. + """ + + identity_dict = get_identity_dict_from_signed_access_token(signed_billing_access_token) + try: + remote_server = get_remote_server_by_uuid(identity_dict["remote_server_uuid"]) + remote_realm = RemoteRealm.objects.get( + uuid=identity_dict["remote_realm_uuid"], server=remote_server + ) + except ObjectDoesNotExist: + raise AssertionError + + obj = PreregistrationRemoteRealmBillingUser.objects.create( + email=email, + remote_realm=remote_realm, + user_uuid=identity_dict["user"]["user_uuid"], + next_page=identity_dict["next_page"], + uri_scheme=identity_dict["uri_scheme"], + ) + url = create_remote_billing_confirmation_link( + obj, + Confirmation.REMOTE_REALM_BILLING_LEGACY_LOGIN, + # Use the same expiration time as for the signed access token, + # since this is similarly transient in nature. + validity_in_minutes=int(REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS / 60), + ) + + context = { + "remote_server_hostname": remote_server.hostname, + "remote_realm_host": remote_realm.host, + "confirmation_url": url, + } + send_email( + "zerver/emails/remote_realm_billing_confirm_login", + to_emails=[email], + from_address=FromAddress.tokenized_no_reply_address(), + context=context, + ) + + return render( + request, + "corporate/remote_billing_email_confirmation_sent.html", + context={"email": email}, + ) + + +@self_hosting_management_endpoint +@typed_endpoint +def remote_realm_billing_from_login_confirmation_link( + request: HttpRequest, + *, + confirmation_key: PathOnly[str], +) -> HttpResponse: + """ + The user comes here via the confirmation link they received via email. + Creates the RemoteRealmBillingUser object and redirects to + remote_realm_billing_finalize_login with a new signed access token, + where they will finally be logged in now that they have an account. + """ + try: + prereg_object = get_object_from_key( + confirmation_key, + [Confirmation.REMOTE_REALM_BILLING_LEGACY_LOGIN], + # These links aren't reusable. The user just clicks it + # to get their account created. Afterwards, they're not + # subject to the confirmation link part of the flow anymore. + mark_object_used=True, + ) + except ConfirmationKeyError as exception: + return render_confirmation_key_error(request, exception) + assert isinstance(prereg_object, PreregistrationRemoteRealmBillingUser) + remote_realm = prereg_object.remote_realm + + uri_scheme = prereg_object.uri_scheme + next_page = prereg_object.next_page + assert next_page in VALID_NEXT_PAGES + assert uri_scheme in ["http://", "https://"] + # Mypy is not satisfied by the above assert, so we need to cast. + uri_scheme = cast(Literal["http://", "https://"], uri_scheme) + + remote_billing_user = RemoteRealmBillingUser.objects.create( + email=prereg_object.email, + remote_realm=remote_realm, + user_uuid=prereg_object.user_uuid, + ) + + identity_dict = RemoteBillingIdentityDict( + user=RemoteBillingUserDict( + user_email=remote_billing_user.email, + user_uuid=str(remote_billing_user.user_uuid), + user_full_name=remote_billing_user.full_name, + ), + remote_server_uuid=str(remote_realm.server.uuid), + remote_realm_uuid=str(remote_realm.uuid), + # This will be figured out by the next endpoint in the flow anyway. + remote_billing_user_id=None, + authenticated_at=datetime_to_timestamp(timezone_now()), + uri_scheme=uri_scheme, + next_page=next_page, + ) + + signed_identity_dict = signing.dumps(identity_dict) + + return HttpResponseRedirect( + reverse(remote_realm_billing_finalize_login, args=[signed_identity_dict]) + ) + + +def create_remote_billing_confirmation_link( + obj: Union[PreregistrationRemoteRealmBillingUser, PreregistrationRemoteServerBillingUser], + confirmation_type: int, + validity_in_minutes: int, +) -> str: + url = create_confirmation_link( + obj, + confirmation_type, + validity_in_minutes=validity_in_minutes, + no_associated_realm_object=True, + ) + + # create_confirmation_link will create the url on the root subdomain, so we need to + # do a hacky approach to change it into the self hosting management subdomain. + new_hostname = f"{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}" + split_url = urlsplit(url) + modified_url = split_url._replace(netloc=new_hostname) + final_url = urlunsplit(modified_url) + + return final_url + + def render_tmp_remote_billing_page( request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str] ) -> HttpResponse: @@ -417,26 +602,18 @@ def remote_billing_legacy_server_confirm_login( remote_server=remote_server, next_page=next_page, ) - url = create_confirmation_link( + url = create_remote_billing_confirmation_link( obj, Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN, # Use the same expiration time as for the signed access token, # since this is similarly transient in nature. validity_in_minutes=int(REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS / 60), - no_associated_realm_object=True, ) - # create_confirmation_link will create the url on the root subdomain, so we need to - # do a hacky approach to change it into the self hosting management subdomain. - new_hostname = f"{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}" - split_url = urlsplit(url) - modified_url = split_url._replace(netloc=new_hostname) - final_url = urlunsplit(modified_url) - context = { "remote_server_hostname": remote_server.hostname, "remote_server_uuid": str(remote_server.uuid), - "confirmation_url": final_url, + "confirmation_url": url, } send_email( "zerver/emails/remote_billing_legacy_server_confirm_login", @@ -447,7 +624,7 @@ def remote_billing_legacy_server_confirm_login( return render( request, - "corporate/remote_billing_legacy_server_confirm_login_sent.html", + "corporate/remote_billing_email_confirmation_sent.html", context={"email": email}, ) diff --git a/templates/corporate/remote_billing_legacy_server_confirm_login_sent.html b/templates/corporate/remote_billing_email_confirmation_sent.html similarity index 100% rename from templates/corporate/remote_billing_legacy_server_confirm_login_sent.html rename to templates/corporate/remote_billing_email_confirmation_sent.html diff --git a/templates/corporate/remote_billing_legacy_server_confirm_login_form.html b/templates/corporate/remote_billing_legacy_server_confirm_login_form.html index d1d8250f87..23ef1681bf 100644 --- a/templates/corporate/remote_billing_legacy_server_confirm_login_form.html +++ b/templates/corporate/remote_billing_legacy_server_confirm_login_form.html @@ -21,7 +21,7 @@ {% endif %}