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