remote_billing: Add a "confirm login" page in RemoteRealm auth flow.

This commit is contained in:
Mateusz Mandera
2023-12-05 01:31:50 +01:00
committed by Tim Abbott
parent 04bb60a05e
commit 250b52e3dc
8 changed files with 297 additions and 9 deletions

View File

@@ -18,7 +18,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
from zilencer.models import RemoteRealm, RemoteRealmBillingUser
if TYPE_CHECKING:
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
@@ -27,7 +27,11 @@ if TYPE_CHECKING:
@override_settings(PUSH_NOTIFICATION_BOUNCER_URL="https://push.zulip.org.example.com")
class RemoteBillingAuthenticationTest(BouncerTestCase):
def execute_remote_billing_authentication_flow(
self, user: UserProfile, next_page: Optional[str] = None
self,
user: UserProfile,
next_page: Optional[str] = None,
expect_tos: bool = True,
confirm_tos: bool = True,
) -> "TestHttpResponse":
now = timezone_now()
@@ -42,10 +46,24 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
# We've received a redirect to an URL that will grant us an authenticated
# session for remote billing.
signed_auth_url = result["Location"]
with time_machine.travel(now, tick=False):
result = self.client_get(result["Location"], subdomain="selfhosting")
# When successful, we receive a final redirect.
self.assertEqual(result.status_code, 302)
result = self.client_get(signed_auth_url, subdomain="selfhosting")
# When successful, we see a confirmation page.
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)
params = {}
if expect_tos:
self.assert_in_success_response(["I agree", "Terms of Service"], result)
if confirm_tos:
params = {"tos_consent": "true"}
result = self.client_post(signed_auth_url, params, subdomain="selfhosting")
if result.status_code >= 400:
# Failures should be returned early so the caller can assert about them.
return result
# Verify the authed data that should have been stored in the session.
identity_dict = RemoteBillingIdentityDict(
@@ -128,6 +146,62 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
self.assert_in_success_response(["Your remote user info:"], result)
self.assert_in_success_response([desdemona.delivery_email], result)
@responses.activate
def test_remote_billing_authentication_flow_tos_consent_failure(self) -> None:
self.login("desdemona")
desdemona = self.example_user("desdemona")
self.add_mock_response()
send_realms_only_to_push_bouncer()
result = self.execute_remote_billing_authentication_flow(
desdemona,
expect_tos=True,
confirm_tos=False,
)
self.assert_json_error(result, "You must accept the Terms of Service to proceed.")
@responses.activate
def test_remote_billing_authentication_flow_tos_consent_update(self) -> None:
self.login("desdemona")
desdemona = self.example_user("desdemona")
self.add_mock_response()
send_realms_only_to_push_bouncer()
with self.settings(TERMS_OF_SERVICE_VERSION="1.0"):
result = self.execute_remote_billing_authentication_flow(
desdemona,
expect_tos=True,
confirm_tos=True,
)
self.assertEqual(result.status_code, 302)
remote_billing_user = RemoteRealmBillingUser.objects.last()
assert remote_billing_user is not None
self.assertEqual(remote_billing_user.user_uuid, desdemona.uuid)
self.assertEqual(remote_billing_user.tos_version, "1.0")
# Now bump the ToS version. They need to agree again.
with self.settings(TERMS_OF_SERVICE_VERSION="2.0"):
result = self.execute_remote_billing_authentication_flow(
desdemona,
expect_tos=True,
confirm_tos=False,
)
self.assert_json_error(result, "You must accept the Terms of Service to proceed.")
result = self.execute_remote_billing_authentication_flow(
desdemona,
expect_tos=True,
confirm_tos=True,
)
remote_billing_user.refresh_from_db()
self.assertEqual(remote_billing_user.user_uuid, desdemona.uuid)
self.assertEqual(remote_billing_user.tos_version, "2.0")
@responses.activate
def test_remote_billing_authentication_flow_expired_session(self) -> None:
now = timezone_now()
@@ -174,7 +248,13 @@ class RemoteBillingAuthenticationTest(BouncerTestCase):
# flow via execute_remote_billing_authentication_flow with next_page="plans".
# So let's test that and assert that we end up successfully re-authed on the /plans
# page.
result = self.execute_remote_billing_authentication_flow(desdemona, next_page="plans")
result = self.execute_remote_billing_authentication_flow(
desdemona,
next_page="plans",
# ToS has already been confirmed earlier.
expect_tos=False,
confirm_tos=False,
)
self.assertEqual(result["Location"], f"/realm/{realm.uuid!s}/plans/")
result = self.client_get(result["Location"], subdomain="selfhosting")
self.assert_in_success_response(["Your remote user info:"], result)

View File

@@ -3,6 +3,7 @@ from typing import Any, Dict, Literal, Optional
from django.conf import settings
from django.core import signing
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
@@ -29,8 +30,13 @@ 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.timestamp import datetime_to_timestamp
from zerver.lib.typed_endpoint import typed_endpoint
from zilencer.models import RemoteRealm, RemoteZulipServer, get_remote_server_by_uuid
from zerver.lib.typed_endpoint import PathOnly, typed_endpoint
from zilencer.models import (
RemoteRealm,
RemoteRealmBillingUser,
RemoteZulipServer,
get_remote_server_by_uuid,
)
billing_logger = logging.getLogger("corporate.stripe")
@@ -83,10 +89,17 @@ def remote_realm_billing_entry(
@self_hosting_management_endpoint
@typed_endpoint
def remote_realm_billing_finalize_login(
request: HttpRequest,
signed_billing_access_token: str,
*,
signed_billing_access_token: PathOnly[str],
tos_consent: Literal[None, "true"] = None,
) -> HttpResponse:
if request.method not in ["GET", "POST"]:
return HttpResponseNotAllowed(["GET", "POST"])
tos_consent_given = tos_consent == "true"
# Sanity assert, because otherwise these make no sense.
assert (
REMOTE_BILLING_SIGNED_ACCESS_TOKEN_VALIDITY_IN_SECONDS
@@ -103,7 +116,82 @@ def remote_realm_billing_finalize_login(
except signing.BadSignature:
raise JsonableError(_("Invalid billing access token."))
# Now we want to fetch (or create) 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.
remote_realm_uuid = identity_dict["remote_realm_uuid"]
remote_server_uuid = identity_dict["remote_server_uuid"]
try:
remote_server = get_remote_server_by_uuid(remote_server_uuid)
remote_realm = RemoteRealm.objects.get(uuid=remote_realm_uuid, server=remote_server)
except ObjectDoesNotExist:
# These should definitely still exist, since the access token was signed
# pretty recently. (And we generally don't delete these at all.)
raise AssertionError
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 (
settings.TERMS_OF_SERVICE_VERSION is not None
), "This is only run on the bouncer, which has ToS"
try:
remote_user = RemoteRealmBillingUser.objects.get(
remote_realm=remote_realm,
user_uuid=user_uuid,
)
tos_consent_needed = int(settings.TERMS_OF_SERVICE_VERSION.split(".")[0]) > int(
remote_user.tos_version.split(".")[0]
)
except RemoteRealmBillingUser.DoesNotExist:
# This is the first time this user is logging in, so ToS consent needed.
tos_consent_needed = True
if request.method == "GET":
context = {
"remote_server_uuid": remote_server_uuid,
"remote_realm_uuid": remote_realm_uuid,
"remote_realm_host": remote_realm.host,
"user_email": user_email,
"user_full_name": user_full_name,
"tos_consent_needed": tos_consent_needed,
"action_url": reverse(
remote_realm_billing_finalize_login, args=(signed_billing_access_token,)
),
}
return render(
request,
"corporate/remote_realm_billing_finalize_login_confirmation.html",
context=context,
)
assert request.method == "POST"
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
remote_user.tos_version = settings.TERMS_OF_SERVICE_VERSION
remote_user.save(update_fields=["email", "full_name", "tos_version"])
request.session["remote_billing_identities"] = {}
request.session["remote_billing_identities"][

View File

@@ -0,0 +1,40 @@
{% extends "zerver/portico.html" %}
{% set entrypoint = "upgrade" %}
{% block title %}
<title>{{ _("Billing login confirmation") }} | Zulip</title>
{% endblock %}
{% block portico_content %}
<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>
</div>
<div class="white-box">
<p>Click <b>Continue</b> to log in to Zulip server
billing with the account below.</p>
Full name: {{ user_full_name }}<br />
Email: {{ user_email }}<br />
<form id="remote-realm-confirm-login-form" method="post" action="{{ action_url }}">
{{ csrf_input }}
{% if tos_consent_needed %}
<div class="input-group terms-of-service">
<label for="id_terms" class="inline-block checkbox">
<input id="id_terms" name="tos_consent" class="required" type="checkbox" value="true" />
<span></span>
{% trans %}I agree to the <a href="{{ root_domain_url }}/policies/terms" target="_blank" rel="noopener noreferrer">Terms of Service</a>.{% endtrans %}
</label>
</div>
{% endif %}
<div class="upgrade-button-container">
<button type="submit" id="remote-realm-confirm-login-button" class="stripe-button-el invoice-button">
<span class="remote-realm-confirm-login-button-text">Continue</span>
<img class="loader remote-realm-confirm-login-button-loader" src="{{ static('images/loading/loader-white.svg') }}" alt="" />
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
import $ from "jquery";
export function initialize(): void {
$("#remote-realm-confirm-login-form").find(".loader").hide();
$("#remote-realm-confirm-login-form").validate({
errorClass: "text-error",
wrapper: "div",
submitHandler(form) {
$("#remote-realm-confirm-login-form").find(".loader").show();
$("#remote-realm-confirm-login-button .remote-realm-confirm-login-button-text").hide();
form.submit();
},
invalidHandler() {
$("#remote-realm-confirm-login-form .alert.alert-error").remove();
},
showErrors(error_map) {
if (error_map.id_terms) {
$("#remote-realm-confirm-login-form .alert.alert-error").remove();
}
this.defaultShowErrors!();
},
});
}
$(() => {
initialize();
});

View File

@@ -674,3 +674,7 @@ input[name="licenses"] {
#upgrade-page-details #due-today-for-future-update-wrapper {
display: none;
}
#remote-realm-confirm-login-form .text-error {
margin-bottom: 15px;
}

View File

@@ -28,6 +28,7 @@
"./src/billing/upgrade",
"jquery-validation",
"./src/billing/legacy_server_login",
"./src/billing/remote_billing_confirmation",
"./styles/portico/billing.css"
],
"billing-event-status": [

View File

@@ -0,0 +1,34 @@
# Generated by Django 4.2.7 on 2023-12-05 01:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zilencer", "0043_remotepushdevicetoken_remote_realm"),
]
operations = [
migrations.CreateModel(
name="RemoteRealmBillingUser",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("user_uuid", models.UUIDField()),
("full_name", models.TextField(default="")),
("email", models.EmailField(max_length=254)),
("tos_version", models.TextField(default="-1")),
(
"remote_realm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zilencer.remoterealm"
),
),
],
),
]

View File

@@ -150,6 +150,18 @@ class RemoteRealm(models.Model):
return f"{self.host} {str(self.uuid)[0:12]}"
class RemoteRealmBillingUser(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()
TOS_VERSION_BEFORE_FIRST_LOGIN = UserProfile.TOS_VERSION_BEFORE_FIRST_LOGIN
tos_version = models.TextField(default=TOS_VERSION_BEFORE_FIRST_LOGIN)
class RemoteZulipServerAuditLog(AbstractRealmAuditLog):
"""Audit data associated with a remote Zulip server (not specific to a
realm). Used primarily for tracking registration and billing