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.
This commit is contained in:
Mateusz Mandera 2023-12-08 19:00:04 +01:00 committed by Tim Abbott
parent bba02044f5
commit abdfdeffe4
17 changed files with 617 additions and 51 deletions

View File

@ -4,7 +4,7 @@ __revision__ = "$Id: models.py 28 2009-10-22 15:03:02Z jarek.zgoda $"
import secrets import secrets
from base64 import b32encode from base64 import b32encode
from datetime import timedelta 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 urllib.parse import urljoin
from django.conf import settings from django.conf import settings
@ -30,6 +30,9 @@ from zerver.models import (
UserProfile, UserProfile,
) )
if settings.ZILENCER_ENABLED:
from zilencer.models import PreregistrationRemoteServerBillingUser
class ConfirmationKeyError(Exception): class ConfirmationKeyError(Exception):
WRONG_LENGTH = 1 WRONG_LENGTH = 1
@ -56,7 +59,7 @@ def generate_key() -> str:
return b32encode(secrets.token_bytes(15)).decode().lower() return b32encode(secrets.token_bytes(15)).decode().lower()
ConfirmationObjT: TypeAlias = Union[ NoZilencerConfirmationObjT: TypeAlias = Union[
MultiuseInvite, MultiuseInvite,
PreregistrationRealm, PreregistrationRealm,
PreregistrationUser, PreregistrationUser,
@ -64,6 +67,11 @@ ConfirmationObjT: TypeAlias = Union[
UserProfile, UserProfile,
RealmReactivationStatus, RealmReactivationStatus,
] ]
ZilencerConfirmationObjT: TypeAlias = Union[
NoZilencerConfirmationObjT, "PreregistrationRemoteServerBillingUser"
]
ConfirmationObjT = Union[NoZilencerConfirmationObjT, ZilencerConfirmationObjT]
def get_object_from_key( def get_object_from_key(
@ -130,6 +138,7 @@ def create_confirmation_link(
if no_associated_realm_object: if no_associated_realm_object:
realm = None realm = None
else: else:
obj = cast(NoZilencerConfirmationObjT, obj)
assert not isinstance(obj, PreregistrationRealm) assert not isinstance(obj, PreregistrationRealm)
realm = obj.realm realm = obj.realm
@ -187,6 +196,7 @@ class Confirmation(models.Model):
MULTIUSE_INVITE = 6 MULTIUSE_INVITE = 6
REALM_CREATION = 7 REALM_CREATION = 7
REALM_REACTIVATION = 8 REALM_REACTIVATION = 8
REMOTE_SERVER_BILLING_LEGACY_LOGIN = 9
type = models.PositiveSmallIntegerField() type = models.PositiveSmallIntegerField()
class Meta: class Meta:
@ -223,6 +233,10 @@ _properties = {
Confirmation.REALM_CREATION: ConfirmationType("get_prereg_key_and_redirect"), Confirmation.REALM_CREATION: ConfirmationType("get_prereg_key_and_redirect"),
Confirmation.REALM_REACTIVATION: ConfirmationType("realm_reactivation"), 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: def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str:

View File

@ -11,7 +11,7 @@ from typing_extensions import Concatenate, ParamSpec
from corporate.lib.remote_billing_util import ( from corporate.lib.remote_billing_util import (
RemoteBillingIdentityExpiredError, RemoteBillingIdentityExpiredError,
get_remote_realm_from_session, 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 corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
from zerver.lib.exceptions import RemoteBillingAuthenticationError 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 raise TypeError("server_uuid must be a string") # nocoverage
try: 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): except (RemoteBillingIdentityExpiredError, RemoteBillingAuthenticationError):
# In this flow, we can only redirect to our local "legacy server flow login" page. # 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 # 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) 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 view_func(request, billing_session)
return _wrapped_view_func return _wrapped_view_func

View File

@ -1,5 +1,5 @@
import logging 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.http import HttpRequest
from django.utils.timezone import now as timezone_now 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.exceptions import JsonableError, RemoteBillingAuthenticationError
from zerver.lib.timestamp import datetime_to_timestamp 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") billing_logger = logging.getLogger("corporate.stripe")
@ -39,6 +39,7 @@ class LegacyServerIdentityDict(TypedDict):
# to add more information as appropriate. # to add more information as appropriate.
remote_server_uuid: str remote_server_uuid: str
remote_billing_user_id: Optional[int]
authenticated_at: int authenticated_at: int
@ -128,12 +129,13 @@ def get_remote_realm_from_session(
return remote_realm return remote_realm
def get_remote_server_from_session( def get_remote_server_and_user_from_session(
request: HttpRequest, request: HttpRequest,
server_uuid: str, server_uuid: str,
) -> RemoteZulipServer: ) -> Tuple[RemoteZulipServer, Optional[RemoteServerBillingUser]]:
identity_dict: Optional[LegacyServerIdentityDict] = get_identity_dict_from_session( identity_dict = cast(
request, realm_uuid=None, server_uuid=server_uuid Optional[LegacyServerIdentityDict],
get_identity_dict_from_session(request, realm_uuid=None, server_uuid=server_uuid),
) )
if identity_dict is None: if identity_dict is None:
@ -148,4 +150,15 @@ def get_remote_server_from_session(
if remote_server.deactivated: if remote_server.deactivated:
raise JsonableError(_("Registration is 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

View File

@ -61,6 +61,7 @@ from zilencer.models import (
RemoteRealm, RemoteRealm,
RemoteRealmAuditLog, RemoteRealmAuditLog,
RemoteRealmBillingUser, RemoteRealmBillingUser,
RemoteServerBillingUser,
RemoteZulipServer, RemoteZulipServer,
RemoteZulipServerAuditLog, RemoteZulipServerAuditLog,
get_remote_realm_guest_and_non_guest_count, get_remote_realm_guest_and_non_guest_count,
@ -3132,9 +3133,11 @@ class RemoteServerBillingSession(BillingSession): # nocoverage
def __init__( def __init__(
self, self,
remote_server: RemoteZulipServer, remote_server: RemoteZulipServer,
remote_billing_user: Optional[RemoteServerBillingUser] = None,
support_staff: Optional[UserProfile] = None, support_staff: Optional[UserProfile] = None,
) -> None: ) -> None:
self.remote_server = remote_server self.remote_server = remote_server
self.remote_billing_user = remote_billing_user
if support_staff is not None: if support_staff is not None:
assert support_staff.is_staff assert support_staff.is_staff
self.support_session = True self.support_session = True

View File

@ -4,6 +4,7 @@ from unittest import mock
import responses import responses
import time_machine import time_machine
from django.conf import settings
from django.test import override_settings from django.test import override_settings
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from typing_extensions import override 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.test_classes import BouncerTestCase
from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.timestamp import datetime_to_timestamp
from zerver.models import UserProfile from zerver.models import UserProfile
from zilencer.models import RemoteRealm, RemoteRealmBillingUser from zilencer.models import RemoteRealm, RemoteRealmBillingUser, RemoteServerBillingUser
if TYPE_CHECKING: if TYPE_CHECKING:
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
@ -360,6 +361,110 @@ class LegacyServerLoginTest(BouncerTestCase):
self.uuid = self.server.uuid self.uuid = self.server.uuid
self.secret = self.server.api_key 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'<input type="hidden" name="next_page" value="{next_page}" />'], 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: def test_server_login_get(self) -> None:
result = self.client_get("/serverlogin/", subdomain="selfhosting") result = self.client_get("/serverlogin/", subdomain="selfhosting")
self.assertEqual(result.status_code, 200) 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) self.assert_in_success_response(["Your server registration has been deactivated."], result)
def test_server_login_success_with_no_plan(self) -> None: def test_server_login_success_with_no_plan(self) -> None:
now = timezone_now() hamlet = self.example_user("hamlet")
with time_machine.travel(now, tick=False): result = self.execute_remote_billing_authentication_flow(
result = self.client_post( hamlet.delivery_email, hamlet.full_name, expect_tos=True, confirm_tos=True
"/serverlogin/",
{"server_org_id": self.uuid, "server_org_secret": self.secret},
subdomain="selfhosting",
) )
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/") 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") result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting")
# The server has no plan, so the /billing page redirects to /upgrade # The server has no plan, so the /billing page redirects to /upgrade
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
@ -433,7 +524,24 @@ class LegacyServerLoginTest(BouncerTestCase):
result = self.client_get(result["Location"], subdomain="selfhosting") result = self.client_get(result["Location"], subdomain="selfhosting")
self.assert_in_success_response([f"Upgrade {self.server.hostname}"], result) 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: def test_server_login_success_with_next_page(self) -> None:
hamlet = self.example_user("hamlet")
# First test an invalid next_page value. # First test an invalid next_page value.
result = self.client_post( result = self.client_post(
"/serverlogin/", "/serverlogin/",
@ -442,15 +550,10 @@ class LegacyServerLoginTest(BouncerTestCase):
) )
self.assert_json_error(result, "Invalid next_page", 400) self.assert_json_error(result, "Invalid next_page", 400)
result = self.client_post( result = self.execute_remote_billing_authentication_flow(
"/serverlogin/", hamlet.delivery_email, hamlet.full_name, next_page="sponsorship"
{
"server_org_id": self.uuid,
"server_org_secret": self.secret,
"next_page": "sponsorship",
},
subdomain="selfhosting",
) )
# We should be redirected to the page dictated by the next_page param. # We should be redirected to the page dictated by the next_page param.
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], f"/server/{self.uuid}/sponsorship/") self.assertEqual(result["Location"], f"/server/{self.uuid}/sponsorship/")
@ -478,6 +581,7 @@ class LegacyServerLoginTest(BouncerTestCase):
) )
def test_server_billing_unauthed(self) -> None: def test_server_billing_unauthed(self) -> None:
hamlet = self.example_user("hamlet")
now = timezone_now() now = timezone_now()
# Try to open a page with no auth at all. # Try to open a page with no auth at all.
result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting") result = self.client_get(f"/server/{self.uuid}/billing/", subdomain="selfhosting")
@ -490,17 +594,30 @@ class LegacyServerLoginTest(BouncerTestCase):
['<input type="hidden" name="next_page" value="billing" />'], result ['<input type="hidden" name="next_page" value="billing" />'], 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 # Now authenticate, going to the /upgrade page since we'll be able to access
# it directly without annoying extra redirects. # it directly without annoying extra redirects.
with time_machine.travel(now, tick=False): with time_machine.travel(now, tick=False):
result = self.client_post( result = self.execute_remote_billing_authentication_flow(
"/serverlogin/", hamlet.delivery_email, hamlet.full_name, next_page="upgrade"
{
"server_org_id": self.uuid,
"server_org_secret": self.secret,
"next_page": "upgrade",
},
subdomain="selfhosting",
) )
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
@ -521,3 +638,12 @@ class LegacyServerLoginTest(BouncerTestCase):
result = self.client_get(f"/server/{self.uuid}/upgrade/", subdomain="selfhosting") result = self.client_get(f"/server/{self.uuid}/upgrade/", subdomain="selfhosting")
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/serverlogin/?next_page=upgrade") 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.")

View File

@ -32,6 +32,8 @@ from corporate.views.portico import (
team_view, team_view,
) )
from corporate.views.remote_billing_page import ( 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_billing_legacy_server_login,
remote_realm_billing_finalize_login, remote_realm_billing_finalize_login,
) )
@ -222,6 +224,16 @@ urlpatterns += [
remote_billing_legacy_server_login, remote_billing_legacy_server_login,
name="remote_billing_legacy_server_login", name="remote_billing_legacy_server_login",
), ),
path(
"serverlogin/<server_uuid>/confirm/",
remote_billing_legacy_server_confirm_login,
name="remote_billing_legacy_server_confirm_login",
),
path(
"serverlogin/do_confirm/<confirmation_key>",
remote_billing_legacy_server_from_login_confirmation_link,
name="remote_billing_legacy_server_from_login_confirmation_link",
),
path( path(
"realm/<realm_uuid>/billing/event_status/", "realm/<realm_uuid>/billing/event_status/",
remote_realm_event_status_page, remote_realm_event_status_page,

View File

@ -1,5 +1,6 @@
import logging import logging
from typing import Any, Dict, Literal, Optional from typing import Any, Dict, Literal, Optional
from urllib.parse import urlsplit, urlunsplit
from django.conf import settings from django.conf import settings
from django.core import signing 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 django.views.decorators.csrf import csrf_exempt
from pydantic import Json 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.decorator import self_hosting_management_endpoint
from corporate.lib.remote_billing_util import ( from corporate.lib.remote_billing_util import (
REMOTE_BILLING_SESSION_VALIDITY_SECONDS, REMOTE_BILLING_SESSION_VALIDITY_SECONDS,
LegacyServerIdentityDict, LegacyServerIdentityDict,
RemoteBillingIdentityDict, RemoteBillingIdentityDict,
RemoteBillingIdentityExpiredError,
RemoteBillingUserDict, RemoteBillingUserDict,
get_identity_dict_from_session, 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.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling
from zerver.lib.response import json_success 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.timestamp import datetime_to_timestamp
from zerver.lib.typed_endpoint import PathOnly, typed_endpoint from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
from zilencer.models import ( from zilencer.models import (
PreregistrationRemoteServerBillingUser,
RemoteRealm, RemoteRealm,
RemoteRealmBillingUser, RemoteRealmBillingUser,
RemoteServerBillingUser,
RemoteZulipServer, RemoteZulipServer,
get_remote_server_by_uuid, get_remote_server_by_uuid,
) )
@ -154,7 +171,7 @@ def remote_realm_billing_finalize_login(
context = { context = {
"remote_server_uuid": remote_server_uuid, "remote_server_uuid": remote_server_uuid,
"remote_realm_uuid": remote_realm_uuid, "remote_realm_uuid": remote_realm_uuid,
"remote_realm_host": remote_realm.host, "host": remote_realm.host,
"user_email": user_email, "user_email": user_email,
"user_full_name": user_full_name, "user_full_name": user_full_name,
"tos_consent_needed": tos_consent_needed, "tos_consent_needed": tos_consent_needed,
@ -332,14 +349,190 @@ def remote_billing_legacy_server_login(
if remote_server.deactivated: if remote_server.deactivated:
context.update({"error_message": _("Your server registration has been deactivated.")}) context.update({"error_message": _("Your server registration has been deactivated.")})
return render(request, "corporate/legacy_server_login.html", context) return render(request, "corporate/legacy_server_login.html", context)
remote_server_uuid = str(remote_server.uuid) 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"] = {}
request.session["remote_billing_identities"][ request.session["remote_billing_identities"][
f"remote_server:{remote_server_uuid}" f"remote_server:{remote_server_uuid}"
] = LegacyServerIdentityDict( ] = LegacyServerIdentityDict(
remote_server_uuid=remote_server_uuid, remote_server_uuid=remote_server_uuid,
authenticated_at=datetime_to_timestamp(timezone_now()), 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 assert next_page in VALID_NEXT_PAGES

View File

@ -0,0 +1,36 @@
{% extends "zerver/portico_signup.html" %}
{% set entrypoint = "upgrade" %}
{% block title %}
<title>{{ _("Login confirmation - email") }} | Zulip</title>
{% endblock %}
{% block portico_content %}
<div class="register-account flex full-page">
<div class="center-block new-style">
<div class="pitch">
<h1>Enter your email address</h1>
<p>Next, we will send a verification email to the address you provide.</p>
</div>
<div class="white-box">
<form id="server-confirm-login-form" method="post" action="{{ action_url }}">
{{ csrf_input }}
{% if next_page %}
<input type="hidden" name="next_page" value="{{ next_page }}" />
{% endif %}
<div class="input-box server-login-form-field">
<label for="email" class="inline-block label-title">Email</label>
<input id="email" name="email" class="email required" type="email" />
</div>
<div class="upgrade-button-container">
<button type="submit" id="server-confirm-login-button" class="stripe-button-el invoice-button">
<span class="server-login-button-text">Continue</span>
<img class="loader server-login-button-loader" src="{{ static('images/loading/loader-white.svg') }}" alt="" />
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "zerver/portico_signup.html" %}
{% set entrypoint = "upgrade" %}
{% block title %}
<title>{{ _("Confirm your email address") }} | Zulip</title>
{% endblock %}
{% block portico_content %}
<div class="app portico-page">
<div class="app-main portico-page-container center-block flex full-page account-creation account-email-confirm-container new-style">
<div class="inline-block">
<div class="get-started">
<h1>{{ _("Confirm your email address") }}</h1>
</div>
<div class="white-box">
<p>{% trans %}To complete the login process, check your email account (<span class="user_email semi-bold">{{ email }}</span>) for a confirmation email from Zulip.{% endtrans %}</p>
{% include 'zerver/dev_env_email_access_details.html' %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block customhead %}
{{ super() }}
{% endblock %}

View File

@ -9,15 +9,23 @@
<div id="remote-realm-confirm-login-page" class="register-account flex full-page"> <div id="remote-realm-confirm-login-page" class="register-account flex full-page">
<div class="center-block new-style"> <div class="center-block new-style">
<div class="pitch"> <div class="pitch">
<h1>Log in to Zulip server billing for {{ remote_realm_host }}</h1> <h1>Log in to Zulip server billing for {{ host }}</h1>
</div> </div>
<div class="white-box"> <div class="white-box">
<p>Click <b>Continue</b> to log in to Zulip server <p>Click <b>Continue</b> to log in to Zulip server
billing with the account below.</p> billing with the account below.</p>
{% if not legacy_server_confirmation_flow %}
Full name: {{ user_full_name }}<br /> Full name: {{ user_full_name }}<br />
{% endif %}
Email: {{ user_email }}<br /> Email: {{ user_email }}<br />
<form id="remote-realm-confirm-login-form" method="post" action="{{ action_url }}"> <form id="remote-realm-confirm-login-form" method="post" action="{{ action_url }}">
{{ csrf_input }} {{ csrf_input }}
{% if legacy_server_confirmation_flow %}
<div class="input-box remote-realm-confirm-login-form-field">
<label for="full_name" class="inline-block label-title">Full name</label>
<input id="full_name" name="full_name" class="required" type="text"/>
</div>
{% endif %}
{% if tos_consent_needed %} {% if tos_consent_needed %}
<div class="input-group terms-of-service"> <div class="input-group terms-of-service">
<label for="id_terms" class="inline-block checkbox"> <label for="id_terms" class="inline-block checkbox">

View File

@ -0,0 +1,22 @@
{% extends "zerver/emails/email_base_default.html" %}
{% block illustration %}
<img src="{{ email_images_base_url }}/registration_confirmation.png" alt=""/>
{% endblock %}
{% block content %}
<p>
{{ _("You have initiated login to the Zulip server billing management system for the following server:") }}
<ul>
<li>{% trans %}Hostname: {{ remote_server_hostname }}{% endtrans %}</li>
<li>{% trans %}zulip_org_id: {{ remote_server_uuid }}{% endtrans %}</li>
</ul>
</p>
<p>
{{ _("Click the button below to complete the login process.") }}
<a class="button" href="{{ confirmation_url }}">{{ _("Confirm login") }}</a>
</p>
<p>
{{macros.contact_us_zulip_cloud(support_email)}}
</p>
{% endblock %}

View File

@ -0,0 +1 @@
{% trans %}Confirm login to Zulip server billing management{% endtrans %}

View File

@ -0,0 +1,9 @@
{{ _("You have initiated login to the Zulip server billing management system for the following server:") }}
* {% trans %}Hostname: {{ remote_server_hostname }}{% endtrans %}
* {% trans %}zulip_org_id: {{ remote_server_uuid }}{% endtrans %}
{{ _("Click the link below to complete the login process;") }}
<{{ confirmation_url }}>
{% trans %}Do you have questions or feedback to share? Contact us at {{ support_email }} — we'd love to help!{% endtrans %}

View File

@ -148,6 +148,8 @@ IGNORED_PHRASES = [
r"guest", r"guest",
# Used in pills for deactivated users. # Used in pills for deactivated users.
r"deactivated", r"deactivated",
# This is a reference to a setting/secret and should be lowercase.
r"zulip_org_id",
] ]
# Sort regexes in descending order of their lengths. As a result, the # Sort regexes in descending order of their lengths. As a result, the

View File

@ -1,12 +1,14 @@
import $ from "jquery"; import $ from "jquery";
export function initialize(): void { export function initialize(): void {
$("#server-login-form").validate({ $("#server-login-form, #server-confirm-login-form").validate({
errorClass: "text-error", errorClass: "text-error",
wrapper: "div", wrapper: "div",
submitHandler(form) { submitHandler(form) {
$("#server-login-form").find(".loader").css("display", "inline-block"); $("#server-login-form").find(".loader").css("display", "inline-block");
$("#server-login-button .server-login-button-text").hide(); $("#server-login-button .server-login-button-text").hide();
$("#server-confirm-login-form").find(".loader").css("display", "inline-block");
$("#server-confirm-login-button .server-login-button-text").hide();
form.submit(); form.submit();
}, },
@ -14,10 +16,12 @@ export function initialize(): void {
// this removes all previous errors that were put on screen // this removes all previous errors that were put on screen
// by the server. // by the server.
$("#server-login-form .alert.alert-error").remove(); $("#server-login-form .alert.alert-error").remove();
$("#server-confirm-login-form .alert.alert-error").remove();
}, },
showErrors(error_map) { showErrors(error_map) {
if (error_map.password) { if (error_map.password) {
$("#server-login-form .alert.alert-error").remove(); $("#server-login-form .alert.alert-error").remove();
$("#server-confirm-login-form .alert.alert-error").remove();
} }
this.defaultShowErrors!(); this.defaultShowErrors!();
}, },

View File

@ -0,0 +1,58 @@
# Generated by Django 4.2.8 on 2023-12-08 19:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zilencer", "0046_remotezulipserver_last_audit_log_update"),
]
operations = [
migrations.CreateModel(
name="PreregistrationRemoteServerBillingUser",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("email", models.EmailField(max_length=254)),
("status", models.IntegerField(default=0)),
("next_page", models.TextField(null=True)),
(
"remote_server",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zilencer.remotezulipserver"
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="RemoteServerBillingUser",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("email", models.EmailField(max_length=254)),
("full_name", models.TextField(default="")),
(
"remote_server",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zilencer.remotezulipserver"
),
),
],
options={
"unique_together": {("remote_server", "email")},
},
),
]

View File

@ -165,6 +165,32 @@ class RemoteRealmBillingUser(models.Model):
tos_version = models.TextField(default=TOS_VERSION_BEFORE_FIRST_LOGIN) tos_version = models.TextField(default=TOS_VERSION_BEFORE_FIRST_LOGIN)
class AbstractRemoteServerBillingUser(models.Model):
remote_server = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE)
email = models.EmailField()
class Meta:
abstract = True
class RemoteServerBillingUser(AbstractRemoteServerBillingUser):
full_name = models.TextField(default="")
class Meta:
unique_together = [
("remote_server", "email"),
]
class PreregistrationRemoteServerBillingUser(AbstractRemoteServerBillingUser):
# status: whether an object has been confirmed.
# if confirmed, set to confirmation.settings.STATUS_USED
status = models.IntegerField(default=0)
next_page = models.TextField(null=True)
class RemoteZulipServerAuditLog(AbstractRealmAuditLog): class RemoteZulipServerAuditLog(AbstractRealmAuditLog):
"""Audit data associated with a remote Zulip server (not specific to a """Audit data associated with a remote Zulip server (not specific to a
realm). Used primarily for tracking registration and billing realm). Used primarily for tracking registration and billing