Show a confirmation page during user password reset (#8004)
This PR adds a confirmation step to resetting your user password between clicking the link in your email and your password actually being reset. This is to better align our password reset flow with the industry standard of requiring a confirmation from the user after email validation.
This commit is contained in:
parent
e44e9ee518
commit
a3a90ee031
24
UPGRADE.rst
24
UPGRADE.rst
|
@ -88,6 +88,30 @@ for example:
|
|||
wget https://packages.matrix.org/debian/pool/main/m/matrix-synapse-py3/matrix-synapse-py3_1.3.0+stretch1_amd64.deb
|
||||
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb
|
||||
|
||||
Upgrading to v1.21.0
|
||||
====================
|
||||
|
||||
New HTML templates
|
||||
------------------
|
||||
|
||||
A new HTML template,
|
||||
`password_reset_confirmation.html <https://github.com/matrix-org/synapse/blob/develop/synapse/res/templates/password_reset_confirmation.html>`_,
|
||||
has been added to the ``synapse/res/templates`` directory. If you are using a
|
||||
custom template directory, you may want to copy the template over and modify it.
|
||||
|
||||
Note that as of v1.20.0, templates do not need to be included in custom template
|
||||
directories for Synapse to start. The default templates will be used if a custom
|
||||
template cannot be found.
|
||||
|
||||
This page will appear to the user after clicking a password reset link that has
|
||||
been emailed to them.
|
||||
|
||||
To complete password reset, the page must include a way to make a `POST`
|
||||
request to
|
||||
``/_synapse/client/password_reset/{medium}/submit_token``
|
||||
with the query parameters from the original link, presented as a URL-encoded form. See the file
|
||||
itself for more details.
|
||||
|
||||
Upgrading to v1.18.0
|
||||
====================
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Require the user to confirm that their password should be reset after clicking the email confirmation link.
|
|
@ -2039,9 +2039,13 @@ email:
|
|||
# * The contents of password reset emails sent by the homeserver:
|
||||
# 'password_reset.html' and 'password_reset.txt'
|
||||
#
|
||||
# * HTML pages for success and failure that a user will see when they follow
|
||||
# the link in the password reset email: 'password_reset_success.html' and
|
||||
# 'password_reset_failure.html'
|
||||
# * An HTML page that a user will see when they follow the link in the password
|
||||
# reset email. The user will be asked to confirm the action before their
|
||||
# password is reset: 'password_reset_confirmation.html'
|
||||
#
|
||||
# * HTML pages for success and failure that a user will see when they confirm
|
||||
# the password reset flow using the page above: 'password_reset_success.html'
|
||||
# and 'password_reset_failure.html'
|
||||
#
|
||||
# * The contents of address verification emails sent during registration:
|
||||
# 'registration.html' and 'registration.txt'
|
||||
|
|
|
@ -21,6 +21,7 @@ from urllib.parse import urlencode
|
|||
|
||||
from synapse.config import ConfigError
|
||||
|
||||
SYNAPSE_CLIENT_API_PREFIX = "/_synapse/client"
|
||||
CLIENT_API_PREFIX = "/_matrix/client"
|
||||
FEDERATION_PREFIX = "/_matrix/federation"
|
||||
FEDERATION_V1_PREFIX = FEDERATION_PREFIX + "/v1"
|
||||
|
|
|
@ -48,6 +48,7 @@ from synapse.api.urls import (
|
|||
from synapse.app import _base
|
||||
from synapse.app._base import listen_ssl, listen_tcp, quit_with_error
|
||||
from synapse.config._base import ConfigError
|
||||
from synapse.config.emailconfig import ThreepidBehaviour
|
||||
from synapse.config.homeserver import HomeServerConfig
|
||||
from synapse.config.server import ListenerConfig
|
||||
from synapse.federation.transport.server import TransportLayerServer
|
||||
|
@ -209,6 +210,15 @@ class SynapseHomeServer(HomeServer):
|
|||
|
||||
resources["/_matrix/saml2"] = SAML2Resource(self)
|
||||
|
||||
if self.get_config().threepid_behaviour_email == ThreepidBehaviour.LOCAL:
|
||||
from synapse.rest.synapse.client.password_reset import (
|
||||
PasswordResetSubmitTokenResource,
|
||||
)
|
||||
|
||||
resources[
|
||||
"/_synapse/client/password_reset/email/submit_token"
|
||||
] = PasswordResetSubmitTokenResource(self)
|
||||
|
||||
if name == "consent":
|
||||
from synapse.rest.consent.consent_resource import ConsentResource
|
||||
|
||||
|
|
|
@ -228,6 +228,7 @@ class EmailConfig(Config):
|
|||
self.email_registration_template_text,
|
||||
self.email_add_threepid_template_html,
|
||||
self.email_add_threepid_template_text,
|
||||
self.email_password_reset_template_confirmation_html,
|
||||
self.email_password_reset_template_failure_html,
|
||||
self.email_registration_template_failure_html,
|
||||
self.email_add_threepid_template_failure_html,
|
||||
|
@ -242,6 +243,7 @@ class EmailConfig(Config):
|
|||
registration_template_text,
|
||||
add_threepid_template_html,
|
||||
add_threepid_template_text,
|
||||
"password_reset_confirmation.html",
|
||||
password_reset_template_failure_html,
|
||||
registration_template_failure_html,
|
||||
add_threepid_template_failure_html,
|
||||
|
@ -404,9 +406,13 @@ class EmailConfig(Config):
|
|||
# * The contents of password reset emails sent by the homeserver:
|
||||
# 'password_reset.html' and 'password_reset.txt'
|
||||
#
|
||||
# * HTML pages for success and failure that a user will see when they follow
|
||||
# the link in the password reset email: 'password_reset_success.html' and
|
||||
# 'password_reset_failure.html'
|
||||
# * An HTML page that a user will see when they follow the link in the password
|
||||
# reset email. The user will be asked to confirm the action before their
|
||||
# password is reset: 'password_reset_confirmation.html'
|
||||
#
|
||||
# * HTML pages for success and failure that a user will see when they confirm
|
||||
# the password reset flow using the page above: 'password_reset_success.html'
|
||||
# and 'password_reset_failure.html'
|
||||
#
|
||||
# * The contents of address verification emails sent during registration:
|
||||
# 'registration.html' and 'registration.txt'
|
||||
|
|
|
@ -123,7 +123,7 @@ class Mailer:
|
|||
params = {"token": token, "client_secret": client_secret, "sid": sid}
|
||||
link = (
|
||||
self.hs.config.public_baseurl
|
||||
+ "_matrix/client/unstable/password_reset/email/submit_token?%s"
|
||||
+ "_synapse/client/password_reset/email/submit_token?%s"
|
||||
% urllib.parse.urlencode(params)
|
||||
)
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<!--Use a hidden form to resubmit the information necessary to reset the password-->
|
||||
<form method="post">
|
||||
<input type="hidden" name="sid" value="{{ sid }}">
|
||||
<input type="hidden" name="token" value="{{ token }}">
|
||||
<input type="hidden" name="client_secret" value="{{ client_secret }}">
|
||||
|
||||
<p>You have requested to <strong>reset your Matrix account password</strong>. Click the link below to confirm this action. <br /><br />
|
||||
If you did not mean to do this, please close this page and your password will not be changed.</p>
|
||||
<p><button type="submit">Confirm changing my password</button></p>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -13,8 +13,8 @@
|
|||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import synapse.rest.admin
|
||||
from synapse.http.server import JsonResource
|
||||
from synapse.rest import admin
|
||||
from synapse.rest.client import versions
|
||||
from synapse.rest.client.v1 import (
|
||||
directory,
|
||||
|
@ -123,9 +123,7 @@ class ClientRestResource(JsonResource):
|
|||
password_policy.register_servlets(hs, client_resource)
|
||||
|
||||
# moving to /_synapse/admin
|
||||
synapse.rest.admin.register_servlets_for_client_rest_resource(
|
||||
hs, client_resource
|
||||
)
|
||||
admin.register_servlets_for_client_rest_resource(hs, client_resource)
|
||||
|
||||
# unstable
|
||||
shared_rooms.register_servlets(hs, client_resource)
|
||||
|
|
|
@ -152,81 +152,6 @@ class EmailPasswordRequestTokenRestServlet(RestServlet):
|
|||
return 200, ret
|
||||
|
||||
|
||||
class PasswordResetSubmitTokenServlet(RestServlet):
|
||||
"""Handles 3PID validation token submission"""
|
||||
|
||||
PATTERNS = client_patterns(
|
||||
"/password_reset/(?P<medium>[^/]*)/submit_token$", releases=(), unstable=True
|
||||
)
|
||||
|
||||
def __init__(self, hs):
|
||||
"""
|
||||
Args:
|
||||
hs (synapse.server.HomeServer): server
|
||||
"""
|
||||
super(PasswordResetSubmitTokenServlet, self).__init__()
|
||||
self.hs = hs
|
||||
self.auth = hs.get_auth()
|
||||
self.config = hs.config
|
||||
self.clock = hs.get_clock()
|
||||
self.store = hs.get_datastore()
|
||||
if self.config.threepid_behaviour_email == ThreepidBehaviour.LOCAL:
|
||||
self._failure_email_template = (
|
||||
self.config.email_password_reset_template_failure_html
|
||||
)
|
||||
|
||||
async def on_GET(self, request, medium):
|
||||
# We currently only handle threepid token submissions for email
|
||||
if medium != "email":
|
||||
raise SynapseError(
|
||||
400, "This medium is currently not supported for password resets"
|
||||
)
|
||||
if self.config.threepid_behaviour_email == ThreepidBehaviour.OFF:
|
||||
if self.config.local_threepid_handling_disabled_due_to_email_config:
|
||||
logger.warning(
|
||||
"Password reset emails have been disabled due to lack of an email config"
|
||||
)
|
||||
raise SynapseError(
|
||||
400, "Email-based password resets are disabled on this server"
|
||||
)
|
||||
|
||||
sid = parse_string(request, "sid", required=True)
|
||||
token = parse_string(request, "token", required=True)
|
||||
client_secret = parse_string(request, "client_secret", required=True)
|
||||
assert_valid_client_secret(client_secret)
|
||||
|
||||
# Attempt to validate a 3PID session
|
||||
try:
|
||||
# Mark the session as valid
|
||||
next_link = await self.store.validate_threepid_session(
|
||||
sid, client_secret, token, self.clock.time_msec()
|
||||
)
|
||||
|
||||
# Perform a 302 redirect if next_link is set
|
||||
if next_link:
|
||||
if next_link.startswith("file:///"):
|
||||
logger.warning(
|
||||
"Not redirecting to next_link as it is a local file: address"
|
||||
)
|
||||
else:
|
||||
request.setResponseCode(302)
|
||||
request.setHeader("Location", next_link)
|
||||
finish_request(request)
|
||||
return None
|
||||
|
||||
# Otherwise show the success template
|
||||
html = self.config.email_password_reset_template_success_html_content
|
||||
status_code = 200
|
||||
except ThreepidValidationError as e:
|
||||
status_code = e.code
|
||||
|
||||
# Show a failure page with a reason
|
||||
template_vars = {"failure_reason": e.msg}
|
||||
html = self._failure_email_template.render(**template_vars)
|
||||
|
||||
respond_with_html(request, status_code, html)
|
||||
|
||||
|
||||
class PasswordRestServlet(RestServlet):
|
||||
PATTERNS = client_patterns("/account/password$")
|
||||
|
||||
|
@ -938,7 +863,6 @@ class WhoamiRestServlet(RestServlet):
|
|||
|
||||
def register_servlets(hs, http_server):
|
||||
EmailPasswordRequestTokenRestServlet(hs).register(http_server)
|
||||
PasswordResetSubmitTokenServlet(hs).register(http_server)
|
||||
PasswordRestServlet(hs).register(http_server)
|
||||
DeactivateAccountRestServlet(hs).register(http_server)
|
||||
EmailThreepidRequestTokenRestServlet(hs).register(http_server)
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
|
@ -0,0 +1,14 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
|
@ -0,0 +1,127 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Tuple
|
||||
|
||||
from twisted.web.http import Request
|
||||
|
||||
from synapse.api.errors import ThreepidValidationError
|
||||
from synapse.config.emailconfig import ThreepidBehaviour
|
||||
from synapse.http.server import DirectServeHtmlResource
|
||||
from synapse.http.servlet import parse_string
|
||||
from synapse.util.stringutils import assert_valid_client_secret
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from synapse.server import HomeServer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PasswordResetSubmitTokenResource(DirectServeHtmlResource):
|
||||
"""Handles 3PID validation token submission
|
||||
|
||||
This resource gets mounted under /_synapse/client/password_reset/email/submit_token
|
||||
"""
|
||||
|
||||
isLeaf = 1
|
||||
|
||||
def __init__(self, hs: "HomeServer"):
|
||||
"""
|
||||
Args:
|
||||
hs: server
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self.clock = hs.get_clock()
|
||||
self.store = hs.get_datastore()
|
||||
|
||||
self._local_threepid_handling_disabled_due_to_email_config = (
|
||||
hs.config.local_threepid_handling_disabled_due_to_email_config
|
||||
)
|
||||
self._confirmation_email_template = (
|
||||
hs.config.email_password_reset_template_confirmation_html
|
||||
)
|
||||
self._email_password_reset_template_success_html = (
|
||||
hs.config.email_password_reset_template_success_html_content
|
||||
)
|
||||
self._failure_email_template = (
|
||||
hs.config.email_password_reset_template_failure_html
|
||||
)
|
||||
|
||||
# This resource should not be mounted if threepid behaviour is not LOCAL
|
||||
assert hs.config.threepid_behaviour_email == ThreepidBehaviour.LOCAL
|
||||
|
||||
async def _async_render_GET(self, request: Request) -> Tuple[int, bytes]:
|
||||
sid = parse_string(request, "sid", required=True)
|
||||
token = parse_string(request, "token", required=True)
|
||||
client_secret = parse_string(request, "client_secret", required=True)
|
||||
assert_valid_client_secret(client_secret)
|
||||
|
||||
# Show a confirmation page, just in case someone accidentally clicked this link when
|
||||
# they didn't mean to
|
||||
template_vars = {
|
||||
"sid": sid,
|
||||
"token": token,
|
||||
"client_secret": client_secret,
|
||||
}
|
||||
return (
|
||||
200,
|
||||
self._confirmation_email_template.render(**template_vars).encode("utf-8"),
|
||||
)
|
||||
|
||||
async def _async_render_POST(self, request: Request) -> Tuple[int, bytes]:
|
||||
sid = parse_string(request, "sid", required=True)
|
||||
token = parse_string(request, "token", required=True)
|
||||
client_secret = parse_string(request, "client_secret", required=True)
|
||||
|
||||
# Attempt to validate a 3PID session
|
||||
try:
|
||||
# Mark the session as valid
|
||||
next_link = await self.store.validate_threepid_session(
|
||||
sid, client_secret, token, self.clock.time_msec()
|
||||
)
|
||||
|
||||
# Perform a 302 redirect if next_link is set
|
||||
if next_link:
|
||||
if next_link.startswith("file:///"):
|
||||
logger.warning(
|
||||
"Not redirecting to next_link as it is a local file: address"
|
||||
)
|
||||
else:
|
||||
next_link_bytes = next_link.encode("utf-8")
|
||||
request.setHeader("Location", next_link_bytes)
|
||||
return (
|
||||
302,
|
||||
(
|
||||
b'You are being redirected to <a src="%s">%s</a>.'
|
||||
% (next_link_bytes, next_link_bytes)
|
||||
),
|
||||
)
|
||||
|
||||
# Otherwise show the success template
|
||||
html_bytes = self._email_password_reset_template_success_html.encode(
|
||||
"utf-8"
|
||||
)
|
||||
status_code = 200
|
||||
except ThreepidValidationError as e:
|
||||
status_code = e.code
|
||||
|
||||
# Show a failure page with a reason
|
||||
template_vars = {"failure_reason": e.msg}
|
||||
html_bytes = self._failure_email_template.render(**template_vars).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
return status_code, html_bytes
|
|
@ -19,6 +19,7 @@ import os
|
|||
import re
|
||||
from email.parser import Parser
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pkg_resources
|
||||
|
||||
|
@ -27,6 +28,7 @@ from synapse.api.constants import LoginType, Membership
|
|||
from synapse.api.errors import Codes
|
||||
from synapse.rest.client.v1 import login, room
|
||||
from synapse.rest.client.v2_alpha import account, register
|
||||
from synapse.rest.synapse.client.password_reset import PasswordResetSubmitTokenResource
|
||||
|
||||
from tests import unittest
|
||||
from tests.unittest import override_config
|
||||
|
@ -70,6 +72,7 @@ class PasswordResetTestCase(unittest.HomeserverTestCase):
|
|||
|
||||
def prepare(self, reactor, clock, hs):
|
||||
self.store = hs.get_datastore()
|
||||
self.submit_token_resource = PasswordResetSubmitTokenResource(hs)
|
||||
|
||||
def test_basic_password_reset(self):
|
||||
"""Test basic password reset flow
|
||||
|
@ -251,8 +254,32 @@ class PasswordResetTestCase(unittest.HomeserverTestCase):
|
|||
# Remove the host
|
||||
path = link.replace("https://example.com", "")
|
||||
|
||||
# Load the password reset confirmation page
|
||||
request, channel = self.make_request("GET", path, shorthand=False)
|
||||
self.render(request)
|
||||
request.render(self.submit_token_resource)
|
||||
self.pump()
|
||||
self.assertEquals(200, channel.code, channel.result)
|
||||
|
||||
# Now POST to the same endpoint, mimicking the same behaviour as clicking the
|
||||
# password reset confirm button
|
||||
|
||||
# Send arguments as url-encoded form data, matching the template's behaviour
|
||||
form_args = []
|
||||
for key, value_list in request.args.items():
|
||||
for value in value_list:
|
||||
arg = (key, value)
|
||||
form_args.append(arg)
|
||||
|
||||
# Confirm the password reset
|
||||
request, channel = self.make_request(
|
||||
"POST",
|
||||
path,
|
||||
content=urlencode(form_args).encode("utf8"),
|
||||
shorthand=False,
|
||||
content_is_form=True,
|
||||
)
|
||||
request.render(self.submit_token_resource)
|
||||
self.pump()
|
||||
self.assertEquals(200, channel.code, channel.result)
|
||||
|
||||
def _get_link_from_email(self):
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
from io import SEEK_END, BytesIO
|
||||
|
||||
import attr
|
||||
from zope.interface import implementer
|
||||
|
@ -135,6 +135,7 @@ def make_request(
|
|||
request=SynapseRequest,
|
||||
shorthand=True,
|
||||
federation_auth_origin=None,
|
||||
content_is_form=False,
|
||||
):
|
||||
"""
|
||||
Make a web request using the given method and path, feed it the
|
||||
|
@ -150,6 +151,8 @@ def make_request(
|
|||
with the usual REST API path, if it doesn't contain it.
|
||||
federation_auth_origin (bytes|None): if set to not-None, we will add a fake
|
||||
Authorization header pretenting to be the given server name.
|
||||
content_is_form: Whether the content is URL encoded form data. Adds the
|
||||
'Content-Type': 'application/x-www-form-urlencoded' header.
|
||||
|
||||
Returns:
|
||||
Tuple[synapse.http.site.SynapseRequest, channel]
|
||||
|
@ -181,6 +184,8 @@ def make_request(
|
|||
req = request(channel)
|
||||
req.process = lambda: b""
|
||||
req.content = BytesIO(content)
|
||||
# Twisted expects to be at the end of the content when parsing the request.
|
||||
req.content.seek(SEEK_END)
|
||||
req.postpath = list(map(unquote, path[1:].split(b"/")))
|
||||
|
||||
if access_token:
|
||||
|
@ -195,6 +200,12 @@ def make_request(
|
|||
)
|
||||
|
||||
if content:
|
||||
if content_is_form:
|
||||
req.requestHeaders.addRawHeader(
|
||||
b"Content-Type", b"application/x-www-form-urlencoded"
|
||||
)
|
||||
else:
|
||||
# Assume the body is JSON
|
||||
req.requestHeaders.addRawHeader(b"Content-Type", b"application/json")
|
||||
|
||||
req.requestReceived(method, path, b"1.1")
|
||||
|
|
|
@ -353,6 +353,7 @@ class HomeserverTestCase(TestCase):
|
|||
request: Type[T] = SynapseRequest,
|
||||
shorthand: bool = True,
|
||||
federation_auth_origin: str = None,
|
||||
content_is_form: bool = False,
|
||||
) -> Tuple[T, FakeChannel]:
|
||||
"""
|
||||
Create a SynapseRequest at the path using the method and containing the
|
||||
|
@ -368,6 +369,8 @@ class HomeserverTestCase(TestCase):
|
|||
with the usual REST API path, if it doesn't contain it.
|
||||
federation_auth_origin (bytes|None): if set to not-None, we will add a fake
|
||||
Authorization header pretenting to be the given server name.
|
||||
content_is_form: Whether the content is URL encoded form data. Adds the
|
||||
'Content-Type': 'application/x-www-form-urlencoded' header.
|
||||
|
||||
Returns:
|
||||
Tuple[synapse.http.site.SynapseRequest, channel]
|
||||
|
@ -384,6 +387,7 @@ class HomeserverTestCase(TestCase):
|
|||
request,
|
||||
shorthand,
|
||||
federation_auth_origin,
|
||||
content_is_form,
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
|
|
Loading…
Reference in New Issue