remote_billing: Implement confirmation flow for RemoteRealm auth.

The way the flow goes now is this:
1. The user initiaties login via "Billing" in the gear menu.
2. That takes them to `/self-hosted-billing/` (possibly with a
   `next_page` param if we use that for some gear menu options).
3. The server queries the bouncer to give the user a link with a signed
   access token.
4. The user is redirected to that link (on `selfhosting.zulipchat.com`).
Now we have two cases, either the user is logging in for the first time
and already did in the past.
If this is the first time, we have:
5. The user is asked to fill in their email in a form that's shown,
   pre-filled with the value provided inside the signed access token.
   They POST this to the next endpoint.
6. The next endpoint sends a confirmation email to that address and asks
   the user to go check their email.
7. The user clicks the link in their email is taken to the
   from_confirmation endpoint.
8. Their initial RemoteBillingUser is created, a new signed link like in
   (3) is generated and they're transparently taken back to (4),
   where now that they have a RemoteBillingUser, they're handled
   just like a user who already logged in before:
If the user already logged in before, they go straight here:
9. "Confirm login" page - they're shown their information (email and
   full_name), can update
   their full name in the form if they want. They also accept ToS here
   if necessary. They POST this form back to
   the endpoint and finally have a logged in session.
10. They're redirected to billing (or `next_page`) now that they have
    access.
This commit is contained in:
Mateusz Mandera 2023-12-10 17:10:56 +01:00 committed by Tim Abbott
parent 18ec4cd198
commit 423aebf98e
14 changed files with 442 additions and 69 deletions

View File

@ -31,7 +31,10 @@ from zerver.models import (
) )
if settings.ZILENCER_ENABLED: if settings.ZILENCER_ENABLED:
from zilencer.models import PreregistrationRemoteServerBillingUser from zilencer.models import (
PreregistrationRemoteRealmBillingUser,
PreregistrationRemoteServerBillingUser,
)
class ConfirmationKeyError(Exception): class ConfirmationKeyError(Exception):
@ -68,7 +71,9 @@ NoZilencerConfirmationObjT: TypeAlias = Union[
RealmReactivationStatus, RealmReactivationStatus,
] ]
ZilencerConfirmationObjT: TypeAlias = Union[ ZilencerConfirmationObjT: TypeAlias = Union[
NoZilencerConfirmationObjT, "PreregistrationRemoteServerBillingUser" NoZilencerConfirmationObjT,
"PreregistrationRemoteServerBillingUser",
"PreregistrationRemoteRealmBillingUser",
] ]
ConfirmationObjT = Union[NoZilencerConfirmationObjT, ZilencerConfirmationObjT] ConfirmationObjT = Union[NoZilencerConfirmationObjT, ZilencerConfirmationObjT]
@ -197,6 +202,7 @@ class Confirmation(models.Model):
REALM_CREATION = 7 REALM_CREATION = 7
REALM_REACTIVATION = 8 REALM_REACTIVATION = 8
REMOTE_SERVER_BILLING_LEGACY_LOGIN = 9 REMOTE_SERVER_BILLING_LEGACY_LOGIN = 9
REMOTE_REALM_BILLING_LEGACY_LOGIN = 10
type = models.PositiveSmallIntegerField() type = models.PositiveSmallIntegerField()
class Meta: class Meta:
@ -237,6 +243,9 @@ if settings.ZILENCER_ENABLED:
_properties[Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN] = ConfirmationType( _properties[Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN] = ConfirmationType(
"remote_billing_legacy_server_from_login_confirmation_link" "remote_billing_legacy_server_from_login_confirmation_link"
) )
_properties[Confirmation.REMOTE_REALM_BILLING_LEGACY_LOGIN] = ConfirmationType(
"remote_realm_billing_from_login_confirmation_link"
)
def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str: def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str:

View File

@ -28,6 +28,7 @@ class RemoteBillingIdentityDict(TypedDict):
remote_server_uuid: str remote_server_uuid: str
remote_realm_uuid: str remote_realm_uuid: str
remote_billing_user_id: Optional[int]
authenticated_at: int authenticated_at: int
uri_scheme: Literal["http://", "https://"] uri_scheme: Literal["http://", "https://"]
@ -133,9 +134,8 @@ def get_remote_server_and_user_from_session(
request: HttpRequest, request: HttpRequest,
server_uuid: str, server_uuid: str,
) -> Tuple[RemoteZulipServer, Optional[RemoteServerBillingUser]]: ) -> Tuple[RemoteZulipServer, Optional[RemoteServerBillingUser]]:
identity_dict = cast( identity_dict: Optional[LegacyServerIdentityDict] = get_identity_dict_from_session(
Optional[LegacyServerIdentityDict], request, realm_uuid=None, server_uuid=server_uuid
get_identity_dict_from_session(request, realm_uuid=None, server_uuid=server_uuid),
) )
if identity_dict is None: if identity_dict is None:

View File

@ -33,6 +33,10 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
next_page: Optional[str] = None, next_page: Optional[str] = None,
expect_tos: bool = True, expect_tos: bool = True,
confirm_tos: bool = True, confirm_tos: bool = True,
first_time_login: bool = True,
# This only matters if first_time_login is True, since otherwise
# there's no confirmation link to be clicked:
return_without_clicking_confirmation_link: bool = False,
) -> "TestHttpResponse": ) -> "TestHttpResponse":
now = timezone_now() now = timezone_now()
@ -45,12 +49,61 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertIn("http://selfhosting.testserver/remote-billing-login/", result["Location"]) self.assertIn("http://selfhosting.testserver/remote-billing-login/", result["Location"])
# We've received a redirect to an URL that will grant us an authenticated
# session for remote billing.
signed_auth_url = result["Location"] signed_auth_url = result["Location"]
signed_access_token = signed_auth_url.split("/")[-1]
with time_machine.travel(now, tick=False): with time_machine.travel(now, tick=False):
result = self.client_get(signed_auth_url, subdomain="selfhosting") result = self.client_get(signed_auth_url, subdomain="selfhosting")
# When successful, we see a confirmation page.
if first_time_login:
self.assertFalse(RemoteRealmBillingUser.objects.filter(user_uuid=user.uuid).exists())
# When logging in for the first time some extra steps are needed
# to confirm and verify the email address.
self.assertEqual(result.status_code, 200)
self.assert_in_success_response(["Enter your email address"], result)
self.assert_in_success_response([user.realm.host], result)
self.assert_in_success_response(
[f'action="/remote-billing-login/{signed_access_token}/confirm/"'], result
)
with time_machine.travel(now, tick=False):
result = self.client_post(
f"/remote-billing-login/{signed_access_token}/confirm/",
{"email": user.delivery_email},
subdomain="selfhosting",
)
self.assertEqual(result.status_code, 200)
self.assert_in_success_response(
["To complete the login process, check your email account", user.delivery_email],
result,
)
confirmation_url = self.get_confirmation_url_from_outbox(
user.delivery_email,
url_pattern=(
f"{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}"
r"(\S+)>"
),
email_body_contains="Click the link below to complete the login process",
)
if return_without_clicking_confirmation_link:
return result
with time_machine.travel(now, tick=False):
result = self.client_get(confirmation_url, subdomain="selfhosting")
remote_billing_user = RemoteRealmBillingUser.objects.latest("id")
self.assertEqual(remote_billing_user.user_uuid, user.uuid)
self.assertEqual(remote_billing_user.email, user.delivery_email)
# Now we should be redirected again to the /remote-billing-login/ endpoint
# with a new signed_access_token. Now that the email has been confirmed,
# and we have a RemoteRealmBillingUser entry, we'll be in the same position
# as the case where first_time_login=False.
self.assertEqual(result.status_code, 302)
self.assertTrue(result["Location"].startswith("/remote-billing-login/"))
result = self.client_get(result["Location"], subdomain="selfhosting")
# Final confirmation page - just confirm your details, possibly
# agreeing to ToS if needed and an authenticated session will be granted:
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)
self.assert_in_success_response(["Log in to Zulip server billing"], result) self.assert_in_success_response(["Log in to Zulip server billing"], result)
self.assert_in_success_response([user.realm.host], result) self.assert_in_success_response([user.realm.host], result)
@ -67,6 +120,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
return result return result
# Verify the authed data that should have been stored in the session. # Verify the authed data that should have been stored in the session.
remote_billing_user = RemoteRealmBillingUser.objects.get(user_uuid=user.uuid)
identity_dict = RemoteBillingIdentityDict( identity_dict = RemoteBillingIdentityDict(
user=RemoteBillingUserDict( user=RemoteBillingUserDict(
user_email=user.delivery_email, user_email=user.delivery_email,
@ -75,6 +129,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
), ),
remote_server_uuid=str(self.server.uuid), remote_server_uuid=str(self.server.uuid),
remote_realm_uuid=str(user.realm.uuid), remote_realm_uuid=str(user.realm.uuid),
remote_billing_user_id=remote_billing_user.id,
authenticated_at=datetime_to_timestamp(now), authenticated_at=datetime_to_timestamp(now),
uri_scheme="http://", uri_scheme="http://",
next_page=next_page, next_page=next_page,
@ -189,6 +244,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
desdemona, desdemona,
expect_tos=True, expect_tos=True,
confirm_tos=False, confirm_tos=False,
first_time_login=False,
) )
self.assert_json_error(result, "You must accept the Terms of Service to proceed.") self.assert_json_error(result, "You must accept the Terms of Service to proceed.")
@ -196,6 +252,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
desdemona, desdemona,
expect_tos=True, expect_tos=True,
confirm_tos=True, confirm_tos=True,
first_time_login=False,
) )
remote_billing_user.refresh_from_db() remote_billing_user.refresh_from_db()
self.assertEqual(remote_billing_user.user_uuid, desdemona.uuid) self.assertEqual(remote_billing_user.user_uuid, desdemona.uuid)
@ -252,6 +309,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
# ToS has already been confirmed earlier. # ToS has already been confirmed earlier.
expect_tos=False, expect_tos=False,
confirm_tos=False, confirm_tos=False,
first_time_login=False,
) )
self.assertEqual(result["Location"], f"/realm/{realm.uuid!s}/plans/") self.assertEqual(result["Location"], f"/realm/{realm.uuid!s}/plans/")
result = self.client_get(result["Location"], subdomain="selfhosting") result = self.client_get(result["Location"], subdomain="selfhosting")
@ -353,6 +411,28 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
["Upgrade", "Purchase Zulip", "Your subscription will renew automatically."], result ["Upgrade", "Purchase Zulip", "Your subscription will renew automatically."], result
) )
@responses.activate
def test_remote_billing_authentication_flow_cant_access_billing_without_finishing_confirmation(
self,
) -> None:
self.login("desdemona")
desdemona = self.example_user("desdemona")
realm = desdemona.realm
self.add_mock_response()
result = self.execute_remote_billing_authentication_flow(
desdemona,
expect_tos=True,
confirm_tos=False,
first_time_login=True,
return_without_clicking_confirmation_link=True,
)
result = self.client_get(f"/realm/{realm.uuid!s}/billing/", subdomain="selfhosting")
# Access is not allowed. The user doesn't have an IdentityDict in the session, so
# we can't do a nice redirect back to their original server.
self.assertEqual(result.status_code, 401)
class LegacyServerLoginTest(BouncerTestCase): class LegacyServerLoginTest(BouncerTestCase):
@override @override

View File

@ -35,7 +35,9 @@ from corporate.views.remote_billing_page import (
remote_billing_legacy_server_confirm_login, remote_billing_legacy_server_confirm_login,
remote_billing_legacy_server_from_login_confirmation_link, remote_billing_legacy_server_from_login_confirmation_link,
remote_billing_legacy_server_login, remote_billing_legacy_server_login,
remote_realm_billing_confirm_email,
remote_realm_billing_finalize_login, remote_realm_billing_finalize_login,
remote_realm_billing_from_login_confirmation_link,
) )
from corporate.views.session import ( from corporate.views.session import (
start_card_update_stripe_session, start_card_update_stripe_session,
@ -186,6 +188,15 @@ urlpatterns = list(i18n_urlpatterns)
urlpatterns += [ urlpatterns += [
path("remote-billing-login/<signed_billing_access_token>", remote_realm_billing_finalize_login), path("remote-billing-login/<signed_billing_access_token>", remote_realm_billing_finalize_login),
path(
"remote-billing-login/<signed_billing_access_token>/confirm/",
remote_realm_billing_confirm_email,
),
path(
"remote-billing-login/do_confirm/<confirmation_key>",
remote_realm_billing_from_login_confirmation_link,
name="remote_realm_billing_from_login_confirmation_link",
),
# Remote server billing endpoints. # Remote server billing endpoints.
path("realm/<realm_uuid>/plans/", remote_realm_plans_page, name="remote_realm_plans_page"), path("realm/<realm_uuid>/plans/", remote_realm_plans_page, name="remote_realm_plans_page"),
path( path(

View File

@ -1,5 +1,5 @@
import logging import logging
from typing import Any, Dict, Literal, Optional from typing import Any, Dict, Literal, Optional, Union, cast
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
from django.conf import settings from django.conf import settings
@ -42,6 +42,7 @@ from zerver.lib.send_email import FromAddress, send_email
from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.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 (
PreregistrationRemoteRealmBillingUser,
PreregistrationRemoteServerBillingUser, PreregistrationRemoteServerBillingUser,
RemoteRealm, RemoteRealm,
RemoteRealmBillingUser, RemoteRealmBillingUser,
@ -86,6 +87,7 @@ def remote_realm_billing_entry(
), ),
remote_server_uuid=str(remote_server.uuid), remote_server_uuid=str(remote_server.uuid),
remote_realm_uuid=str(remote_realm.uuid), remote_realm_uuid=str(remote_realm.uuid),
remote_billing_user_id=None,
authenticated_at=datetime_to_timestamp(timezone_now()), authenticated_at=datetime_to_timestamp(timezone_now()),
uri_scheme=uri_scheme, uri_scheme=uri_scheme,
next_page=next_page, next_page=next_page,
@ -100,14 +102,34 @@ def remote_realm_billing_entry(
return json_success(request, data={"billing_access_url": billing_access_url}) return json_success(request, data={"billing_access_url": billing_access_url})
def get_identity_dict_from_signed_access_token(
signed_billing_access_token: str,
) -> RemoteBillingIdentityDict:
try:
identity_dict: RemoteBillingIdentityDict = signing.loads(
signed_billing_access_token,
max_age=REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS,
)
except signing.SignatureExpired:
raise JsonableError(_("Billing access token expired."))
except signing.BadSignature:
raise JsonableError(_("Invalid billing access token."))
return identity_dict
@self_hosting_management_endpoint @self_hosting_management_endpoint
@typed_endpoint @typed_endpoint
def remote_realm_billing_finalize_login( def remote_realm_billing_finalize_login(
request: HttpRequest, request: HttpRequest,
*, *,
signed_billing_access_token: PathOnly[str], signed_billing_access_token: PathOnly[str],
full_name: Optional[str] = None,
tos_consent: Literal[None, "true"] = None, tos_consent: Literal[None, "true"] = None,
) -> HttpResponse: ) -> HttpResponse:
"""
This is the endpoint accessed via the billing_access_url, generated by
remote_realm_billing_entry entry.
"""
if request.method not in ["GET", "POST"]: if request.method not in ["GET", "POST"]:
return HttpResponseNotAllowed(["GET", "POST"]) return HttpResponseNotAllowed(["GET", "POST"])
tos_consent_given = tos_consent == "true" tos_consent_given = tos_consent == "true"
@ -118,23 +140,19 @@ def remote_realm_billing_finalize_login(
<= REMOTE_BILLING_SESSION_VALIDITY_SECONDS <= REMOTE_BILLING_SESSION_VALIDITY_SECONDS
) )
try: identity_dict = get_identity_dict_from_signed_access_token(signed_billing_access_token)
identity_dict: RemoteBillingIdentityDict = signing.loads(
signed_billing_access_token,
max_age=REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS,
)
except signing.SignatureExpired:
raise JsonableError(_("Billing access token expired."))
except signing.BadSignature:
raise JsonableError(_("Invalid billing access token."))
# Now we want to fetch (or create) the RemoteRealmBillingUser object implied # Now we want to fetch the RemoteRealmBillingUser object implied
# by the IdentityDict. We'll use this: # by the IdentityDict. We'll use this:
# (1) If the user came here via just GET, we want to show them a confirmation # (1) If the user came here via just GET, we want to show them a confirmation
# page with the relevant info details before finalizing login. If they wish # page with the relevant info details before finalizing login. If they wish
# to proceed, they'll approve the form, causing a POST, bring us to case (2). # to proceed, they'll approve the form, causing a POST, bring us to case (2).
# (2) If the user came here via POST, we finalize login, using the info from the # (2) If the user came here via POST, we finalize login, using the info from the
# IdentityDict to update the RemoteRealmBillingUser object if needed. # IdentityDict to update the RemoteRealmBillingUser object if needed.
# Finally, if the user is logging in for the first time, we'll need to create
# their account first. This will happen by making them fill out a form to confirm
# their email first. Only after clicking the confirmation link in the email,
# they will have their account created and finally be redirected back here.
remote_realm_uuid = identity_dict["remote_realm_uuid"] remote_realm_uuid = identity_dict["remote_realm_uuid"]
remote_server_uuid = identity_dict["remote_server_uuid"] remote_server_uuid = identity_dict["remote_server_uuid"]
try: try:
@ -148,7 +166,6 @@ def remote_realm_billing_finalize_login(
user_dict = identity_dict["user"] user_dict = identity_dict["user"]
user_email = user_dict["user_email"] user_email = user_dict["user_email"]
user_full_name = user_dict["user_full_name"]
user_uuid = user_dict["user_uuid"] user_uuid = user_dict["user_uuid"]
assert ( assert (
@ -164,16 +181,21 @@ def remote_realm_billing_finalize_login(
remote_user.tos_version.split(".")[0] remote_user.tos_version.split(".")[0]
) )
except RemoteRealmBillingUser.DoesNotExist: except RemoteRealmBillingUser.DoesNotExist:
# This is the first time this user is logging in, so ToS consent needed. # This is the first time this user is logging in.
remote_user = None
tos_consent_needed = True tos_consent_needed = True
if request.method == "GET": if request.method == "GET":
if remote_user is not None:
# Render a template where the user will just confirm their info,
# possibly accept ToS if needed, POST back here and will get fully
# authenticated.
context = { 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,
"host": remote_realm.host, "host": remote_realm.host,
"user_email": user_email, "user_email": remote_user.email,
"user_full_name": user_full_name, "user_full_name": remote_user.full_name,
"tos_consent_needed": tos_consent_needed, "tos_consent_needed": tos_consent_needed,
"action_url": reverse( "action_url": reverse(
remote_realm_billing_finalize_login, args=(signed_billing_access_token,) remote_realm_billing_finalize_login, args=(signed_billing_access_token,)
@ -184,27 +206,46 @@ def remote_realm_billing_finalize_login(
"corporate/remote_realm_billing_finalize_login_confirmation.html", "corporate/remote_realm_billing_finalize_login_confirmation.html",
context=context, context=context,
) )
else:
# This user is logging in for the first time, so we need to create their
# RemoteRealmBillingUser object. Render a template where they'll
# enter their email address - we'll send a verification email to it.
context = {
"email": user_email,
"action_url": reverse(
remote_realm_billing_confirm_email, args=(signed_billing_access_token,)
),
}
return render(
request,
# TODO: We're re-using a template originally made for the legacy server
# flow. We should rename it and its HTML contents to a more general name.
"corporate/remote_billing_legacy_server_confirm_login_form.html",
context=context,
)
assert request.method == "POST" assert request.method == "POST"
if remote_user is None:
# Users logging in for the first time need to be created and follow
# a different path - they should not be POSTing here. It should be impossible
# to get here with a remote_user that is None without tampering with the form
# or manualling crafting a POST request.
raise JsonableError(_("User account doesn't exist yet."))
if tos_consent_needed and not tos_consent_given: if tos_consent_needed and not tos_consent_given:
# This shouldn't be possible without tampering with the form, so we # This shouldn't be possible without tampering with the form, so we
# don't need a pretty error. # don't need a pretty error.
raise JsonableError(_("You must accept the Terms of Service to proceed.")) raise JsonableError(_("You must accept the Terms of Service to proceed."))
remote_user, created = RemoteRealmBillingUser.objects.get_or_create( # The current approach is to update the full_name
defaults={"full_name": user_full_name, "email": user_email}, # based on what the user entered in the login confirmation form.
remote_realm=remote_realm, # Usually they'll presumably just use the name already set for this object.
user_uuid=user_uuid, if full_name is not None:
) remote_user.full_name = full_name
# The current approach is to just update the email and full_name
# based on the info provided by the remote server during auth.
remote_user.email = user_email
remote_user.full_name = user_full_name
remote_user.tos_version = settings.TERMS_OF_SERVICE_VERSION remote_user.tos_version = settings.TERMS_OF_SERVICE_VERSION
remote_user.save(update_fields=["email", "full_name", "tos_version"]) remote_user.save(update_fields=["full_name", "tos_version"])
identity_dict["remote_billing_user_id"] = remote_user.id
request.session["remote_billing_identities"] = {} request.session["remote_billing_identities"] = {}
request.session["remote_billing_identities"][ request.session["remote_billing_identities"][
f"remote_realm:{remote_realm_uuid}" f"remote_realm:{remote_realm_uuid}"
@ -224,6 +265,150 @@ def remote_realm_billing_finalize_login(
) )
@self_hosting_management_endpoint
@typed_endpoint
def remote_realm_billing_confirm_email(
request: HttpRequest,
*,
signed_billing_access_token: PathOnly[str],
email: str,
) -> HttpResponse:
"""
Endpoint for users in the RemoteRealm flow that are logging in for the first time
and still have to have their RemoteRealmBillingUser object created.
Takes the POST from the above form asking for their email address
and sends confirmation email to the provided
email address in order to verify. Only the confirmation link will grant
a fully authenticated session.
"""
identity_dict = get_identity_dict_from_signed_access_token(signed_billing_access_token)
try:
remote_server = get_remote_server_by_uuid(identity_dict["remote_server_uuid"])
remote_realm = RemoteRealm.objects.get(
uuid=identity_dict["remote_realm_uuid"], server=remote_server
)
except ObjectDoesNotExist:
raise AssertionError
obj = PreregistrationRemoteRealmBillingUser.objects.create(
email=email,
remote_realm=remote_realm,
user_uuid=identity_dict["user"]["user_uuid"],
next_page=identity_dict["next_page"],
uri_scheme=identity_dict["uri_scheme"],
)
url = create_remote_billing_confirmation_link(
obj,
Confirmation.REMOTE_REALM_BILLING_LEGACY_LOGIN,
# Use the same expiration time as for the signed access token,
# since this is similarly transient in nature.
validity_in_minutes=int(REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS / 60),
)
context = {
"remote_server_hostname": remote_server.hostname,
"remote_realm_host": remote_realm.host,
"confirmation_url": url,
}
send_email(
"zerver/emails/remote_realm_billing_confirm_login",
to_emails=[email],
from_address=FromAddress.tokenized_no_reply_address(),
context=context,
)
return render(
request,
"corporate/remote_billing_email_confirmation_sent.html",
context={"email": email},
)
@self_hosting_management_endpoint
@typed_endpoint
def remote_realm_billing_from_login_confirmation_link(
request: HttpRequest,
*,
confirmation_key: PathOnly[str],
) -> HttpResponse:
"""
The user comes here via the confirmation link they received via email.
Creates the RemoteRealmBillingUser object and redirects to
remote_realm_billing_finalize_login with a new signed access token,
where they will finally be logged in now that they have an account.
"""
try:
prereg_object = get_object_from_key(
confirmation_key,
[Confirmation.REMOTE_REALM_BILLING_LEGACY_LOGIN],
# These links aren't reusable. The user just clicks it
# to get their account created. Afterwards, they're not
# subject to the confirmation link part of the flow anymore.
mark_object_used=True,
)
except ConfirmationKeyError as exception:
return render_confirmation_key_error(request, exception)
assert isinstance(prereg_object, PreregistrationRemoteRealmBillingUser)
remote_realm = prereg_object.remote_realm
uri_scheme = prereg_object.uri_scheme
next_page = prereg_object.next_page
assert next_page in VALID_NEXT_PAGES
assert uri_scheme in ["http://", "https://"]
# Mypy is not satisfied by the above assert, so we need to cast.
uri_scheme = cast(Literal["http://", "https://"], uri_scheme)
remote_billing_user = RemoteRealmBillingUser.objects.create(
email=prereg_object.email,
remote_realm=remote_realm,
user_uuid=prereg_object.user_uuid,
)
identity_dict = RemoteBillingIdentityDict(
user=RemoteBillingUserDict(
user_email=remote_billing_user.email,
user_uuid=str(remote_billing_user.user_uuid),
user_full_name=remote_billing_user.full_name,
),
remote_server_uuid=str(remote_realm.server.uuid),
remote_realm_uuid=str(remote_realm.uuid),
# This will be figured out by the next endpoint in the flow anyway.
remote_billing_user_id=None,
authenticated_at=datetime_to_timestamp(timezone_now()),
uri_scheme=uri_scheme,
next_page=next_page,
)
signed_identity_dict = signing.dumps(identity_dict)
return HttpResponseRedirect(
reverse(remote_realm_billing_finalize_login, args=[signed_identity_dict])
)
def create_remote_billing_confirmation_link(
obj: Union[PreregistrationRemoteRealmBillingUser, PreregistrationRemoteServerBillingUser],
confirmation_type: int,
validity_in_minutes: int,
) -> str:
url = create_confirmation_link(
obj,
confirmation_type,
validity_in_minutes=validity_in_minutes,
no_associated_realm_object=True,
)
# create_confirmation_link will create the url on the root subdomain, so we need to
# do a hacky approach to change it into the self hosting management subdomain.
new_hostname = f"{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}"
split_url = urlsplit(url)
modified_url = split_url._replace(netloc=new_hostname)
final_url = urlunsplit(modified_url)
return final_url
def render_tmp_remote_billing_page( def render_tmp_remote_billing_page(
request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str] request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str]
) -> HttpResponse: ) -> HttpResponse:
@ -417,26 +602,18 @@ def remote_billing_legacy_server_confirm_login(
remote_server=remote_server, remote_server=remote_server,
next_page=next_page, next_page=next_page,
) )
url = create_confirmation_link( url = create_remote_billing_confirmation_link(
obj, obj,
Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN, Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN,
# Use the same expiration time as for the signed access token, # Use the same expiration time as for the signed access token,
# since this is similarly transient in nature. # since this is similarly transient in nature.
validity_in_minutes=int(REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS / 60), 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 = { context = {
"remote_server_hostname": remote_server.hostname, "remote_server_hostname": remote_server.hostname,
"remote_server_uuid": str(remote_server.uuid), "remote_server_uuid": str(remote_server.uuid),
"confirmation_url": final_url, "confirmation_url": url,
} }
send_email( send_email(
"zerver/emails/remote_billing_legacy_server_confirm_login", "zerver/emails/remote_billing_legacy_server_confirm_login",
@ -447,7 +624,7 @@ def remote_billing_legacy_server_confirm_login(
return render( return render(
request, request,
"corporate/remote_billing_legacy_server_confirm_login_sent.html", "corporate/remote_billing_email_confirmation_sent.html",
context={"email": email}, context={"email": email},
) )

View File

@ -21,7 +21,7 @@
{% endif %} {% endif %}
<div class="input-box server-login-form-field"> <div class="input-box server-login-form-field">
<label for="email" class="inline-block label-title">Email</label> <label for="email" class="inline-block label-title">Email</label>
<input id="email" name="email" class="email required" type="email" /> <input id="email" name="email" class="email required" type="email" {% if email %}value="{{ email }}"{% endif %} />
</div> </div>
<div class="upgrade-button-container"> <div class="upgrade-button-container">
<button type="submit" id="server-confirm-login-button" class="stripe-button-el invoice-button"> <button type="submit" id="server-confirm-login-button" class="stripe-button-el invoice-button">

View File

@ -14,18 +14,13 @@
<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 />
{% 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"> <div class="input-box remote-realm-confirm-login-form-field">
<label for="full_name" class="inline-block label-title">Full name</label> <label for="full_name" class="inline-block label-title">Full name</label>
<input id="full_name" name="full_name" class="required" type="text"/> <input id="full_name" name="full_name" class="required" type="text" {% if user_full_name %}value="{{ user_full_name }}"{% endif %} />
</div> </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 plan management system for the following organization:") }}
<ul>
<li>{% trans %}Organization host: {{ remote_realm_host }}{% endtrans %}</li>
<li>{% trans %}Server host: {{ remote_server_hostname }}{% 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 plan management{% endtrans %}

View File

@ -0,0 +1,9 @@
{{ _("You have initiated login to the Zulip plan management system for the following organization:") }}
* {% trans %}Organization host: {{ remote_realm_host }}{% endtrans %}
* {% trans %}Server host: {{ remote_server_hostname }}{% 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

@ -239,7 +239,11 @@ def check_banned_words(text: str) -> List[str]:
if word in lower_cased_text: if word in lower_cased_text:
# Hack: Should move this into BANNED_WORDS framework; for # Hack: Should move this into BANNED_WORDS framework; for
# now, just hand-code the skips: # now, just hand-code the skips:
if "realm_name" in lower_cased_text or "realm_uri" in lower_cased_text: if (
"realm_name" in lower_cased_text
or "realm_uri" in lower_cased_text
or "remote_realm_host" in lower_cased_text
):
continue continue
kwargs = dict(word=word, text=text, reason=reason) kwargs = dict(word=word, text=text, reason=reason)
msg = "{word} found in '{text}'. {reason}".format(**kwargs) msg = "{word} found in '{text}'. {reason}".format(**kwargs)

View File

@ -0,0 +1,42 @@
# Generated by Django 4.2.8 on 2023-12-10 01:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zilencer", "0048_remotezulipserver_last_api_feature_level"),
]
operations = [
migrations.AlterUniqueTogether(
name="remoterealmbillinguser",
unique_together={("remote_realm", "user_uuid")},
),
migrations.CreateModel(
name="PreregistrationRemoteRealmBillingUser",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("user_uuid", models.UUIDField()),
("email", models.EmailField(max_length=254)),
("status", models.IntegerField(default=0)),
("next_page", models.TextField(null=True)),
("uri_scheme", models.TextField()),
(
"remote_realm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zilencer.remoterealm"
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -154,17 +154,40 @@ class RemoteRealm(models.Model):
return f"{self.host} {str(self.uuid)[0:12]}" return f"{self.host} {str(self.uuid)[0:12]}"
class RemoteRealmBillingUser(models.Model): class AbstractRemoteRealmBillingUser(models.Model):
remote_realm = models.ForeignKey(RemoteRealm, on_delete=models.CASCADE) remote_realm = models.ForeignKey(RemoteRealm, on_delete=models.CASCADE)
# The .uuid of the UserProfile on the remote server # The .uuid of the UserProfile on the remote server
user_uuid = models.UUIDField() user_uuid = models.UUIDField()
full_name = models.TextField(default="")
email = models.EmailField() email = models.EmailField()
class Meta:
abstract = True
class RemoteRealmBillingUser(AbstractRemoteRealmBillingUser):
full_name = models.TextField(default="")
TOS_VERSION_BEFORE_FIRST_LOGIN = UserProfile.TOS_VERSION_BEFORE_FIRST_LOGIN TOS_VERSION_BEFORE_FIRST_LOGIN = UserProfile.TOS_VERSION_BEFORE_FIRST_LOGIN
tos_version = models.TextField(default=TOS_VERSION_BEFORE_FIRST_LOGIN) tos_version = models.TextField(default=TOS_VERSION_BEFORE_FIRST_LOGIN)
class Meta:
unique_together = [
("remote_realm", "user_uuid"),
]
class PreregistrationRemoteRealmBillingUser(AbstractRemoteRealmBillingUser):
# status: whether an object has been confirmed.
# if confirmed, set to confirmation.settings.STATUS_USED
status = models.IntegerField(default=0)
# These are for carrying certain information that's originally
# in an IdentityDict across the confirmation link flow. These
# values will be restored in the final, fully authenticated IdentityDict.
next_page = models.TextField(null=True)
uri_scheme = models.TextField()
class AbstractRemoteServerBillingUser(models.Model): class AbstractRemoteServerBillingUser(models.Model):
remote_server = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE) remote_server = models.ForeignKey(RemoteZulipServer, on_delete=models.CASCADE)