From abdfdeffe45ca3537ca4db8728855287ecc92e67 Mon Sep 17 00:00:00 2001 From: Mateusz Mandera Date: Fri, 8 Dec 2023 19:00:04 +0100 Subject: [PATCH] remote_billing: Implement confirmation flow for legacy servers. For the last form (with Full Name and ToS consent field), this pretty shamelessly re-uses and directly renders the corporate/remote_realm_billing_finalize_login_confirmation.html template. That's probably good in terms of re-use, but calls for a clean-up commit that will generalize the name of this template and the classes/ids in the HTML. --- confirmation/models.py | 18 +- corporate/lib/decorator.py | 16 +- corporate/lib/remote_billing_util.py | 27 ++- corporate/lib/stripe.py | 3 + corporate/tests/test_remote_billing.py | 196 +++++++++++++---- corporate/urls.py | 12 ++ corporate/views/remote_billing_page.py | 197 +++++++++++++++++- ...ling_legacy_server_confirm_login_form.html | 36 ++++ ...ling_legacy_server_confirm_login_sent.html | 29 +++ ...m_billing_finalize_login_confirmation.html | 10 +- ...e_billing_legacy_server_confirm_login.html | 22 ++ ...ng_legacy_server_confirm_login.subject.txt | 1 + ...te_billing_legacy_server_confirm_login.txt | 9 + tools/lib/capitalization.py | 2 + web/src/billing/legacy_server_login.ts | 6 +- ...trationremoteserverbillinguser_and_more.py | 58 ++++++ zilencer/models.py | 26 +++ 17 files changed, 617 insertions(+), 51 deletions(-) create mode 100644 templates/corporate/remote_billing_legacy_server_confirm_login_form.html create mode 100644 templates/corporate/remote_billing_legacy_server_confirm_login_sent.html create mode 100644 templates/zerver/emails/remote_billing_legacy_server_confirm_login.html create mode 100644 templates/zerver/emails/remote_billing_legacy_server_confirm_login.subject.txt create mode 100644 templates/zerver/emails/remote_billing_legacy_server_confirm_login.txt create mode 100644 zilencer/migrations/0047_preregistrationremoteserverbillinguser_and_more.py diff --git a/confirmation/models.py b/confirmation/models.py index 1f27dfa940..9106e0f6e2 100644 --- a/confirmation/models.py +++ b/confirmation/models.py @@ -4,7 +4,7 @@ __revision__ = "$Id: models.py 28 2009-10-22 15:03:02Z jarek.zgoda $" import secrets from base64 import b32encode from datetime import timedelta -from typing import List, Mapping, Optional, Union +from typing import List, Mapping, Optional, Union, cast from urllib.parse import urljoin from django.conf import settings @@ -30,6 +30,9 @@ from zerver.models import ( UserProfile, ) +if settings.ZILENCER_ENABLED: + from zilencer.models import PreregistrationRemoteServerBillingUser + class ConfirmationKeyError(Exception): WRONG_LENGTH = 1 @@ -56,7 +59,7 @@ def generate_key() -> str: return b32encode(secrets.token_bytes(15)).decode().lower() -ConfirmationObjT: TypeAlias = Union[ +NoZilencerConfirmationObjT: TypeAlias = Union[ MultiuseInvite, PreregistrationRealm, PreregistrationUser, @@ -64,6 +67,11 @@ ConfirmationObjT: TypeAlias = Union[ UserProfile, RealmReactivationStatus, ] +ZilencerConfirmationObjT: TypeAlias = Union[ + NoZilencerConfirmationObjT, "PreregistrationRemoteServerBillingUser" +] + +ConfirmationObjT = Union[NoZilencerConfirmationObjT, ZilencerConfirmationObjT] def get_object_from_key( @@ -130,6 +138,7 @@ def create_confirmation_link( if no_associated_realm_object: realm = None else: + obj = cast(NoZilencerConfirmationObjT, obj) assert not isinstance(obj, PreregistrationRealm) realm = obj.realm @@ -187,6 +196,7 @@ class Confirmation(models.Model): MULTIUSE_INVITE = 6 REALM_CREATION = 7 REALM_REACTIVATION = 8 + REMOTE_SERVER_BILLING_LEGACY_LOGIN = 9 type = models.PositiveSmallIntegerField() class Meta: @@ -223,6 +233,10 @@ _properties = { Confirmation.REALM_CREATION: ConfirmationType("get_prereg_key_and_redirect"), Confirmation.REALM_REACTIVATION: ConfirmationType("realm_reactivation"), } +if settings.ZILENCER_ENABLED: + _properties[Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN] = ConfirmationType( + "remote_billing_legacy_server_from_login_confirmation_link" + ) def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str: diff --git a/corporate/lib/decorator.py b/corporate/lib/decorator.py index 5357c8b76b..6d01be544a 100644 --- a/corporate/lib/decorator.py +++ b/corporate/lib/decorator.py @@ -11,7 +11,7 @@ from typing_extensions import Concatenate, ParamSpec from corporate.lib.remote_billing_util import ( RemoteBillingIdentityExpiredError, get_remote_realm_from_session, - get_remote_server_from_session, + get_remote_server_and_user_from_session, ) from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession from zerver.lib.exceptions import RemoteBillingAuthenticationError @@ -153,7 +153,14 @@ def authenticated_remote_server_management_endpoint( raise TypeError("server_uuid must be a string") # nocoverage try: - remote_server = get_remote_server_from_session(request, server_uuid=server_uuid) + remote_server, remote_billing_user = get_remote_server_and_user_from_session( + request, server_uuid=server_uuid + ) + if remote_billing_user is None: + # This should only be possible if the user hasn't finished the confirmation flow + # and doesn't have a fully authenticated session yet. They should not be attempting + # to access this endpoint yet. + raise RemoteBillingAuthenticationError except (RemoteBillingIdentityExpiredError, RemoteBillingAuthenticationError): # In this flow, we can only redirect to our local "legacy server flow login" page. # That means that we can do it universally whether the user has an expired @@ -167,7 +174,10 @@ def authenticated_remote_server_management_endpoint( return HttpResponseRedirect(url) - billing_session = RemoteServerBillingSession(remote_server) + assert remote_billing_user is not None + billing_session = RemoteServerBillingSession( + remote_server, remote_billing_user=remote_billing_user + ) return view_func(request, billing_session) return _wrapped_view_func diff --git a/corporate/lib/remote_billing_util.py b/corporate/lib/remote_billing_util.py index ece121dbcb..ac2107bfd1 100644 --- a/corporate/lib/remote_billing_util.py +++ b/corporate/lib/remote_billing_util.py @@ -1,5 +1,5 @@ import logging -from typing import Literal, Optional, TypedDict, Union, cast +from typing import Literal, Optional, Tuple, TypedDict, Union, cast from django.http import HttpRequest from django.utils.timezone import now as timezone_now @@ -7,7 +7,7 @@ from django.utils.translation import gettext as _ from zerver.lib.exceptions import JsonableError, RemoteBillingAuthenticationError from zerver.lib.timestamp import datetime_to_timestamp -from zilencer.models import RemoteRealm, RemoteZulipServer +from zilencer.models import RemoteRealm, RemoteServerBillingUser, RemoteZulipServer billing_logger = logging.getLogger("corporate.stripe") @@ -39,6 +39,7 @@ class LegacyServerIdentityDict(TypedDict): # to add more information as appropriate. remote_server_uuid: str + remote_billing_user_id: Optional[int] authenticated_at: int @@ -128,12 +129,13 @@ def get_remote_realm_from_session( return remote_realm -def get_remote_server_from_session( +def get_remote_server_and_user_from_session( request: HttpRequest, server_uuid: str, -) -> RemoteZulipServer: - identity_dict: Optional[LegacyServerIdentityDict] = get_identity_dict_from_session( - request, realm_uuid=None, server_uuid=server_uuid +) -> Tuple[RemoteZulipServer, Optional[RemoteServerBillingUser]]: + identity_dict = cast( + Optional[LegacyServerIdentityDict], + get_identity_dict_from_session(request, realm_uuid=None, server_uuid=server_uuid), ) if identity_dict is None: @@ -148,4 +150,15 @@ def get_remote_server_from_session( if remote_server.deactivated: raise JsonableError(_("Registration is deactivated")) - return remote_server + remote_billing_user_id = identity_dict.get("remote_billing_user_id") + if remote_billing_user_id is None: + return remote_server, None + + try: + remote_billing_user = RemoteServerBillingUser.objects.get( + id=remote_billing_user_id, remote_server=remote_server + ) + except RemoteServerBillingUser.DoesNotExist: + remote_billing_user = None + + return remote_server, remote_billing_user diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index caba02ee7f..3c719eca9a 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -61,6 +61,7 @@ from zilencer.models import ( RemoteRealm, RemoteRealmAuditLog, RemoteRealmBillingUser, + RemoteServerBillingUser, RemoteZulipServer, RemoteZulipServerAuditLog, get_remote_realm_guest_and_non_guest_count, @@ -3132,9 +3133,11 @@ class RemoteServerBillingSession(BillingSession): # nocoverage def __init__( self, remote_server: RemoteZulipServer, + remote_billing_user: Optional[RemoteServerBillingUser] = None, support_staff: Optional[UserProfile] = None, ) -> None: self.remote_server = remote_server + self.remote_billing_user = remote_billing_user if support_staff is not None: assert support_staff.is_staff self.support_session = True diff --git a/corporate/tests/test_remote_billing.py b/corporate/tests/test_remote_billing.py index c6be9ae8cf..53967107d0 100644 --- a/corporate/tests/test_remote_billing.py +++ b/corporate/tests/test_remote_billing.py @@ -4,6 +4,7 @@ from unittest import mock import responses import time_machine +from django.conf import settings from django.test import override_settings from django.utils.timezone import now as timezone_now from typing_extensions import override @@ -18,7 +19,7 @@ from zerver.lib.remote_server import send_realms_only_to_push_bouncer from zerver.lib.test_classes import BouncerTestCase from zerver.lib.timestamp import datetime_to_timestamp from zerver.models import UserProfile -from zilencer.models import RemoteRealm, RemoteRealmBillingUser +from zilencer.models import RemoteRealm, RemoteRealmBillingUser, RemoteServerBillingUser if TYPE_CHECKING: from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse @@ -360,6 +361,110 @@ class LegacyServerLoginTest(BouncerTestCase): self.uuid = self.server.uuid self.secret = self.server.api_key + def execute_remote_billing_authentication_flow( + self, + email: str, + full_name: str, + next_page: Optional[str] = None, + expect_tos: bool = True, + confirm_tos: bool = True, + return_without_clicking_confirmation_link: bool = False, + ) -> "TestHttpResponse": + now = timezone_now() + with time_machine.travel(now, tick=False): + payload = {"server_org_id": self.uuid, "server_org_secret": self.secret} + if next_page is not None: + payload["next_page"] = next_page + result = self.client_post( + "/serverlogin/", + payload, + subdomain="selfhosting", + ) + + self.assertEqual(result.status_code, 200) + self.assert_in_success_response(["Enter your email address"], result) + if next_page is not None: + self.assert_in_success_response( + [f''], result + ) + self.assert_in_success_response([f'action="/serverlogin/{self.uuid!s}/confirm/"'], result) + + # Verify the partially-authed data that should have been stored in the session. The flow + # isn't complete yet however, and this won't give the user access to authenticated endpoints, + # only allow them to proceed with confirmation. + identity_dict = LegacyServerIdentityDict( + remote_server_uuid=str(self.server.uuid), + authenticated_at=datetime_to_timestamp(now), + remote_billing_user_id=None, + ) + self.assertEqual( + self.client.session["remote_billing_identities"][f"remote_server:{self.uuid!s}"], + identity_dict, + ) + + payload = {"email": email} + if next_page is not None: + payload["next_page"] = next_page + with time_machine.travel(now, tick=False): + result = self.client_post( + f"/serverlogin/{self.uuid!s}/confirm/", + payload, + subdomain="selfhosting", + ) + self.assertEqual(result.status_code, 200) + self.assert_in_success_response( + ["To complete the login process, check your email account", email], result + ) + + confirmation_url = self.get_confirmation_url_from_outbox( + 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") + self.assertEqual(result.status_code, 200) + self.assert_in_success_response( + [f"Log in to Zulip server billing for {self.server.hostname}", email], result + ) + self.assert_in_success_response([f'action="{confirmation_url}"'], result) + if expect_tos: + self.assert_in_success_response(["I agree", "Terms of Service"], result) + + payload = {"full_name": full_name} + if confirm_tos: + payload["tos_consent"] = "true" + with time_machine.travel(now, tick=False): + result = self.client_post(confirmation_url, payload, subdomain="selfhosting") + if result.status_code >= 400: + # Early return for the caller to assert about the error. + return result + + # The user should now be fully authenticated. + + # This should have been created in the process: + remote_billing_user = RemoteServerBillingUser.objects.get( + remote_server=self.server, email=email + ) + + # Verify the session looks as it should: + identity_dict = LegacyServerIdentityDict( + remote_server_uuid=str(self.server.uuid), + authenticated_at=datetime_to_timestamp(now), + remote_billing_user_id=remote_billing_user.id, + ) + self.assertEqual( + self.client.session["remote_billing_identities"][f"remote_server:{self.uuid!s}"], + identity_dict, + ) + + return result + def test_server_login_get(self) -> None: result = self.client_get("/serverlogin/", subdomain="selfhosting") self.assertEqual(result.status_code, 200) @@ -400,27 +505,13 @@ class LegacyServerLoginTest(BouncerTestCase): self.assert_in_success_response(["Your server registration has been deactivated."], result) def test_server_login_success_with_no_plan(self) -> None: - now = timezone_now() - with time_machine.travel(now, tick=False): - result = self.client_post( - "/serverlogin/", - {"server_org_id": self.uuid, "server_org_secret": self.secret}, - subdomain="selfhosting", - ) - + hamlet = self.example_user("hamlet") + result = self.execute_remote_billing_authentication_flow( + hamlet.delivery_email, hamlet.full_name, expect_tos=True, confirm_tos=True + ) self.assertEqual(result.status_code, 302) self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/") - # Verify the authed data that should have been stored in the session. - identity_dict = LegacyServerIdentityDict( - remote_server_uuid=str(self.server.uuid), - authenticated_at=datetime_to_timestamp(now), - ) - self.assertEqual( - self.client.session["remote_billing_identities"][f"remote_server:{self.uuid!s}"], - identity_dict, - ) - result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting") # The server has no plan, so the /billing page redirects to /upgrade self.assertEqual(result.status_code, 302) @@ -433,7 +524,24 @@ class LegacyServerLoginTest(BouncerTestCase): result = self.client_get(result["Location"], subdomain="selfhosting") self.assert_in_success_response([f"Upgrade {self.server.hostname}"], result) + def test_server_login_success_consent_is_not_re_asked(self) -> None: + hamlet = self.example_user("hamlet") + result = self.execute_remote_billing_authentication_flow( + hamlet.delivery_email, hamlet.full_name, expect_tos=True, confirm_tos=True + ) + self.assertEqual(result.status_code, 302) + self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/") + + # Now go through the flow again, but this time we should not be asked to re-confirm ToS. + result = self.execute_remote_billing_authentication_flow( + hamlet.delivery_email, hamlet.full_name, expect_tos=False, confirm_tos=False + ) + self.assertEqual(result.status_code, 302) + self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/") + def test_server_login_success_with_next_page(self) -> None: + hamlet = self.example_user("hamlet") + # First test an invalid next_page value. result = self.client_post( "/serverlogin/", @@ -442,15 +550,10 @@ class LegacyServerLoginTest(BouncerTestCase): ) self.assert_json_error(result, "Invalid next_page", 400) - result = self.client_post( - "/serverlogin/", - { - "server_org_id": self.uuid, - "server_org_secret": self.secret, - "next_page": "sponsorship", - }, - subdomain="selfhosting", + result = self.execute_remote_billing_authentication_flow( + hamlet.delivery_email, hamlet.full_name, next_page="sponsorship" ) + # We should be redirected to the page dictated by the next_page param. self.assertEqual(result.status_code, 302) self.assertEqual(result["Location"], f"/server/{self.uuid}/sponsorship/") @@ -478,6 +581,7 @@ class LegacyServerLoginTest(BouncerTestCase): ) def test_server_billing_unauthed(self) -> None: + hamlet = self.example_user("hamlet") now = timezone_now() # Try to open a page with no auth at all. result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting") @@ -490,17 +594,30 @@ class LegacyServerLoginTest(BouncerTestCase): [''], result ) + # The full auth flow involves clicking a confirmation link, upon which the user is + # granted an authenticated session. However, in the first part of the process, + # an intermittent session state is created to transition between endpoints. + # The bottom line is that this session state should *not* grant the user actual + # access to the billing management endpoints. + # We verify that here by simulating the user *not* clicking the confirmation link, + # and then trying to access billing management with the intermittent session state. + with time_machine.travel(now, tick=False): + self.execute_remote_billing_authentication_flow( + hamlet.delivery_email, + hamlet.full_name, + next_page="upgrade", + return_without_clicking_confirmation_link=True, + ) + result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting") + self.assertEqual(result.status_code, 302) + # Redirects to the login form with appropriate next_page value. + self.assertEqual(result["Location"], "/serverlogin/?next_page=billing") + # Now authenticate, going to the /upgrade page since we'll be able to access # it directly without annoying extra redirects. with time_machine.travel(now, tick=False): - result = self.client_post( - "/serverlogin/", - { - "server_org_id": self.uuid, - "server_org_secret": self.secret, - "next_page": "upgrade", - }, - subdomain="selfhosting", + result = self.execute_remote_billing_authentication_flow( + hamlet.delivery_email, hamlet.full_name, next_page="upgrade" ) self.assertEqual(result.status_code, 302) @@ -521,3 +638,12 @@ class LegacyServerLoginTest(BouncerTestCase): result = self.client_get(f"/server/{self.uuid}/upgrade/", subdomain="selfhosting") self.assertEqual(result.status_code, 302) self.assertEqual(result["Location"], "/serverlogin/?next_page=upgrade") + + def test_remote_billing_authentication_flow_tos_consent_failure(self) -> None: + hamlet = self.example_user("hamlet") + + result = self.execute_remote_billing_authentication_flow( + hamlet.email, hamlet.full_name, expect_tos=True, confirm_tos=False + ) + + self.assert_json_error(result, "You must accept the Terms of Service to proceed.") diff --git a/corporate/urls.py b/corporate/urls.py index 80bf939ddf..fa18bec828 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -32,6 +32,8 @@ from corporate.views.portico import ( team_view, ) 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_finalize_login, ) @@ -222,6 +224,16 @@ urlpatterns += [ remote_billing_legacy_server_login, name="remote_billing_legacy_server_login", ), + path( + "serverlogin//confirm/", + remote_billing_legacy_server_confirm_login, + name="remote_billing_legacy_server_confirm_login", + ), + path( + "serverlogin/do_confirm/", + remote_billing_legacy_server_from_login_confirmation_link, + name="remote_billing_legacy_server_from_login_confirmation_link", + ), path( "realm//billing/event_status/", remote_realm_event_status_page, diff --git a/corporate/views/remote_billing_page.py b/corporate/views/remote_billing_page.py index 1063488e40..89ba9a85f4 100644 --- a/corporate/views/remote_billing_page.py +++ b/corporate/views/remote_billing_page.py @@ -1,5 +1,6 @@ import logging from typing import Any, Dict, Literal, Optional +from urllib.parse import urlsplit, urlunsplit from django.conf import settings from django.core import signing @@ -13,22 +14,38 @@ from django.utils.translation import gettext as _ from django.views.decorators.csrf import csrf_exempt from pydantic import Json +from confirmation.models import ( + Confirmation, + ConfirmationKeyError, + create_confirmation_link, + get_object_from_key, + render_confirmation_key_error, +) from corporate.lib.decorator import self_hosting_management_endpoint from corporate.lib.remote_billing_util import ( REMOTE_BILLING_SESSION_VALIDITY_SECONDS, LegacyServerIdentityDict, RemoteBillingIdentityDict, + RemoteBillingIdentityExpiredError, RemoteBillingUserDict, get_identity_dict_from_session, + get_remote_server_and_user_from_session, +) +from zerver.lib.exceptions import ( + JsonableError, + MissingRemoteRealmError, + RemoteBillingAuthenticationError, ) -from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError from zerver.lib.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling from zerver.lib.response import json_success +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 ( + PreregistrationRemoteServerBillingUser, RemoteRealm, RemoteRealmBillingUser, + RemoteServerBillingUser, RemoteZulipServer, get_remote_server_by_uuid, ) @@ -154,7 +171,7 @@ def remote_realm_billing_finalize_login( context = { "remote_server_uuid": remote_server_uuid, "remote_realm_uuid": remote_realm_uuid, - "remote_realm_host": remote_realm.host, + "host": remote_realm.host, "user_email": user_email, "user_full_name": user_full_name, "tos_consent_needed": tos_consent_needed, @@ -332,14 +349,190 @@ def remote_billing_legacy_server_login( if remote_server.deactivated: context.update({"error_message": _("Your server registration has been deactivated.")}) return render(request, "corporate/legacy_server_login.html", context) + remote_server_uuid = str(remote_server.uuid) + # We will want to render a page with a form that POSTs user-filled data to + # the next endpoint in the flow. That endpoint needs to know the user is already + # authenticated as a billing admin for this remote server, so we need to store + # our usual IdentityDict structure in the session. request.session["remote_billing_identities"] = {} request.session["remote_billing_identities"][ f"remote_server:{remote_server_uuid}" ] = LegacyServerIdentityDict( remote_server_uuid=remote_server_uuid, authenticated_at=datetime_to_timestamp(timezone_now()), + # The lack of remote_billing_user_id indicates the auth hasn't been completed. + # This means access to authenticated endpoints will be denied. Only proceeding + # to the next step in the flow is permitted with this. + remote_billing_user_id=None, + ) + + context = { + "remote_server_hostname": remote_server.hostname, + "next_page": next_page, + "action_url": reverse( + remote_billing_legacy_server_confirm_login, args=(str(remote_server.uuid),) + ), + } + return render( + request, + "corporate/remote_billing_legacy_server_confirm_login_form.html", + context=context, + ) + + +@self_hosting_management_endpoint +@typed_endpoint +def remote_billing_legacy_server_confirm_login( + request: HttpRequest, + *, + server_uuid: PathOnly[str], + email: str, + next_page: VALID_NEXT_PAGES_TYPE = None, +) -> HttpResponse: + """ + Takes the POST from the above form and sends confirmation email to the provided + email address in order to verify. Only the confirmation link will grant + a fully authenticated session. + """ + + try: + remote_server, remote_billing_user = get_remote_server_and_user_from_session( + request, server_uuid=server_uuid + ) + if remote_billing_user is not None: + # This session is already fully authenticated, it doesn't make sense for + # the user to be here. Just raise an exception so it's immediately caught + # and the user is redirected to the beginning of the login flow where + # they can re-auth. + raise RemoteBillingAuthenticationError + except (RemoteBillingIdentityExpiredError, RemoteBillingAuthenticationError): + return HttpResponse( + reverse("remote_billing_legacy_server_login") + f"?next_page={next_page}" + ) + + obj = PreregistrationRemoteServerBillingUser.objects.create( + email=email, + remote_server=remote_server, + next_page=next_page, + ) + url = create_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, + } + send_email( + "zerver/emails/remote_billing_legacy_server_confirm_login", + to_emails=[email], + from_address=FromAddress.tokenized_no_reply_address(), + context=context, + ) + + return render( + request, + "corporate/remote_billing_legacy_server_confirm_login_sent.html", + context={"email": email}, + ) + + +@self_hosting_management_endpoint +@typed_endpoint +def remote_billing_legacy_server_from_login_confirmation_link( + request: HttpRequest, + *, + confirmation_key: PathOnly[str], + full_name: Optional[str] = None, + tos_consent: Literal[None, "true"] = None, +) -> HttpResponse: + """ + The user comes here via the confirmation link they received via email. + """ + if request.method not in ["GET", "POST"]: + return HttpResponseNotAllowed(["GET", "POST"]) + + try: + prereg_object = get_object_from_key( + confirmation_key, + [Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN], + # These links are reusable. + mark_object_used=False, + ) + except ConfirmationKeyError as exception: + return render_confirmation_key_error(request, exception) + assert isinstance(prereg_object, PreregistrationRemoteServerBillingUser) + remote_server = prereg_object.remote_server + remote_server_uuid = str(remote_server.uuid) + + # If this user (identified by email) already did this flow, meaning the have a RemoteServerBillingUser, + # then we don't re-do the ToS consent again. + tos_consent_needed = not RemoteServerBillingUser.objects.filter( + remote_server=remote_server, email=prereg_object.email + ).exists() + + if request.method == "GET": + context = { + "remote_server_uuid": remote_server_uuid, + "host": remote_server.hostname, + "user_email": prereg_object.email, + "tos_consent_needed": tos_consent_needed, + "action_url": reverse( + remote_billing_legacy_server_from_login_confirmation_link, + args=(confirmation_key,), + ), + "legacy_server_confirmation_flow": True, + } + return render( + request, + # TODO: We're re-using the template, so it should be renamed + # to a more general name. + "corporate/remote_realm_billing_finalize_login_confirmation.html", + context=context, + ) + + assert request.method == "POST" + + if tos_consent_needed and not tos_consent: + # 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.")) + + next_page = prereg_object.next_page + + remote_billing_user, created = RemoteServerBillingUser.objects.update_or_create( + defaults={"full_name": full_name}, + email=prereg_object.email, + remote_server=remote_server, + ) + + # Refresh IdentityDict in the session. (Or create it + # if the user came here e.g. in a different browser than they + # started the login flow in.) + request.session["remote_billing_identities"] = {} + request.session["remote_billing_identities"][ + f"remote_server:{remote_server_uuid}" + ] = LegacyServerIdentityDict( + remote_server_uuid=remote_server_uuid, + authenticated_at=datetime_to_timestamp(timezone_now()), + # Having a remote_billing_user_id indicates the auth has been completed. + # The user will now be granted access to authenticated endpoints. + remote_billing_user_id=remote_billing_user.id, ) assert next_page in VALID_NEXT_PAGES diff --git a/templates/corporate/remote_billing_legacy_server_confirm_login_form.html b/templates/corporate/remote_billing_legacy_server_confirm_login_form.html new file mode 100644 index 0000000000..d1d8250f87 --- /dev/null +++ b/templates/corporate/remote_billing_legacy_server_confirm_login_form.html @@ -0,0 +1,36 @@ +{% extends "zerver/portico_signup.html" %} +{% set entrypoint = "upgrade" %} + +{% block title %} +{{ _("Login confirmation - email") }} | Zulip +{% endblock %} + +{% block portico_content %} + +{% endblock %} diff --git a/templates/corporate/remote_billing_legacy_server_confirm_login_sent.html b/templates/corporate/remote_billing_legacy_server_confirm_login_sent.html new file mode 100644 index 0000000000..96103fe6cb --- /dev/null +++ b/templates/corporate/remote_billing_legacy_server_confirm_login_sent.html @@ -0,0 +1,29 @@ +{% extends "zerver/portico_signup.html" %} +{% set entrypoint = "upgrade" %} + +{% block title %} +{{ _("Confirm your email address") }} | Zulip +{% endblock %} + +{% block portico_content %} +
+ +
+{% endblock %} + +{% block customhead %} +{{ super() }} +{% endblock %} diff --git a/templates/corporate/remote_realm_billing_finalize_login_confirmation.html b/templates/corporate/remote_realm_billing_finalize_login_confirmation.html index 61c24cbf40..9dd5ee3958 100644 --- a/templates/corporate/remote_realm_billing_finalize_login_confirmation.html +++ b/templates/corporate/remote_realm_billing_finalize_login_confirmation.html @@ -9,15 +9,23 @@