mirror of https://github.com/zulip/zulip.git
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:
parent
bba02044f5
commit
abdfdeffe4
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'<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:
|
||||
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):
|
|||
['<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
|
||||
# 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.")
|
||||
|
|
|
@ -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/<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(
|
||||
"realm/<realm_uuid>/billing/event_status/",
|
||||
remote_realm_event_status_page,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -9,15 +9,23 @@
|
|||
<div id="remote-realm-confirm-login-page" class="register-account flex full-page">
|
||||
<div class="center-block new-style">
|
||||
<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 class="white-box">
|
||||
<p>Click <b>Continue</b> to log in to Zulip server
|
||||
billing with the account below.</p>
|
||||
{% if not legacy_server_confirmation_flow %}
|
||||
Full name: {{ user_full_name }}<br />
|
||||
{% endif %}
|
||||
Email: {{ user_email }}<br />
|
||||
<form id="remote-realm-confirm-login-form" method="post" action="{{ action_url }}">
|
||||
{{ 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 %}
|
||||
<div class="input-group terms-of-service">
|
||||
<label for="id_terms" class="inline-block checkbox">
|
||||
|
|
|
@ -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 %}
|
|
@ -0,0 +1 @@
|
|||
{% trans %}Confirm login to Zulip server billing management{% endtrans %}
|
|
@ -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 %}
|
|
@ -148,6 +148,8 @@ IGNORED_PHRASES = [
|
|||
r"guest",
|
||||
# Used in pills for deactivated users.
|
||||
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
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import $ from "jquery";
|
||||
|
||||
export function initialize(): void {
|
||||
$("#server-login-form").validate({
|
||||
$("#server-login-form, #server-confirm-login-form").validate({
|
||||
errorClass: "text-error",
|
||||
wrapper: "div",
|
||||
submitHandler(form) {
|
||||
$("#server-login-form").find(".loader").css("display", "inline-block");
|
||||
$("#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();
|
||||
},
|
||||
|
@ -14,10 +16,12 @@ export function initialize(): void {
|
|||
// this removes all previous errors that were put on screen
|
||||
// by the server.
|
||||
$("#server-login-form .alert.alert-error").remove();
|
||||
$("#server-confirm-login-form .alert.alert-error").remove();
|
||||
},
|
||||
showErrors(error_map) {
|
||||
if (error_map.password) {
|
||||
$("#server-login-form .alert.alert-error").remove();
|
||||
$("#server-confirm-login-form .alert.alert-error").remove();
|
||||
}
|
||||
this.defaultShowErrors!();
|
||||
},
|
||||
|
|
|
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -165,6 +165,32 @@ class RemoteRealmBillingUser(models.Model):
|
|||
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):
|
||||
"""Audit data associated with a remote Zulip server (not specific to a
|
||||
realm). Used primarily for tracking registration and billing
|
||||
|
|
Loading…
Reference in New Issue