Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Show a confirmation page during user password reset #8004

Merged
merged 36 commits into from
Sep 10, 2020
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9003eb4
Add confirmation page template
anoadragon453 Aug 21, 2020
5f7a834
Load the new template
anoadragon453 Jul 30, 2020
d9a19fc
Create new password reset confirmation endpoint
anoadragon453 Aug 21, 2020
6140b32
Add confirmation as a step to the password reset unit tests
anoadragon453 Aug 21, 2020
622946e
Remind people about the new template in the upgrade notes
anoadragon453 Aug 21, 2020
3568e18
Add changelog
anoadragon453 Jul 31, 2020
8cc8ee4
Remove unused fields
anoadragon453 Aug 21, 2020
b41b051
typing
anoadragon453 Aug 21, 2020
0a2b11f
Ensure we don't fail a request due to the config
anoadragon453 Aug 21, 2020
1d79f7b
Remove another unused class var
anoadragon453 Aug 21, 2020
3f4e350
Merge branch 'develop' of github.com:matrix-org/synapse into anoa/pas…
anoadragon453 Aug 26, 2020
d6addba
Update synapse/rest/client/v2_alpha/account.py
anoadragon453 Aug 28, 2020
990178f
Pull things from, instead of copying the entirety of, the config
anoadragon453 Aug 28, 2020
1b4458e
Return 400 when accessing submit_token/_confirm with REMOTE behaviour
anoadragon453 Aug 28, 2020
0608fe1
Convert confirmation from request args to HTML form data
anoadragon453 Aug 28, 2020
d9f5b4c
Combine the submit_token and submit_token_confirm endpoints
anoadragon453 Aug 28, 2020
f25209c
Switch from an unstable /_matrix/client prefix to /_synapse/client
anoadragon453 Aug 28, 2020
5060a8e
Update UPGRADE.rst
anoadragon453 Sep 1, 2020
8711981
Merge branch 'develop' of github.com:matrix-org/synapse into anoa/pas…
anoadragon453 Sep 2, 2020
1c1dc03
Merge branch 'anoa/password_reset_confirmation' of github.com:matrix-…
anoadragon453 Sep 2, 2020
9d90642
Add new resource to password reset unit tests
anoadragon453 Sep 2, 2020
7073605
Move PasswordResetSubmitTokenServlet to new synapse/client resource
anoadragon453 Sep 2, 2020
8efa7ac
Move synapse_client_patterns to synapse/client resource folder
anoadragon453 Sep 2, 2020
ad231e3
Add new PasswordResetRestResource JsonResource
anoadragon453 Sep 2, 2020
c1b9ad2
Add PasswordResetRestResource to HomeServer
anoadragon453 Sep 2, 2020
cb7c903
Convert synapse.rest.synapse to a python package
anoadragon453 Sep 3, 2020
76e9000
Rename synapse module to internal due to conflicts
anoadragon453 Sep 3, 2020
717961d
Remove action call from HTML template, remove medium placeholder
anoadragon453 Sep 3, 2020
775e310
Update docstring
anoadragon453 Sep 3, 2020
5ed7cf7
Switch from serlvets to base resources
anoadragon453 Sep 3, 2020
e2f0574
Merge branch 'develop' of github.com:matrix-org/synapse into anoa/pas…
anoadragon453 Sep 9, 2020
c3f6ff5
Inline password reset confirmation template filename
anoadragon453 Sep 9, 2020
2b6bde7
Typing and returning things directly
anoadragon453 Sep 9, 2020
0ad0753
Remove checks for threepid behaviour and add an assert
anoadragon453 Sep 9, 2020
c460e3b
v1.20.0 -> v1.21.0
anoadragon453 Sep 9, 2020
c5af421
Rename 'internal' dir to 'synapse'
anoadragon453 Sep 9, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions UPGRADE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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.20.0
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
====================

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
====================

Expand Down
1 change: 1 addition & 0 deletions changelog.d/8004.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Require the user to confirm that their password should be reset after clicking the email confirmation link.
10 changes: 7 additions & 3 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2021,9 +2021,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'
Expand Down
1 change: 1 addition & 0 deletions synapse/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

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"
Expand Down
15 changes: 12 additions & 3 deletions synapse/config/emailconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ def read_config(self, config, **kwargs):
"add_threepid_template_text", "add_threepid.txt"
)

password_reset_template_confirmation_html = (
"password_reset_confirmation.html"
)
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
password_reset_template_failure_html = email_config.get(
"password_reset_template_failure_html", "password_reset_failure.html"
)
Expand Down Expand Up @@ -228,6 +231,7 @@ def read_config(self, config, **kwargs):
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,
Expand All @@ -242,6 +246,7 @@ def read_config(self, config, **kwargs):
registration_template_text,
add_threepid_template_html,
add_threepid_template_text,
password_reset_template_confirmation_html,
password_reset_template_failure_html,
registration_template_failure_html,
add_threepid_template_failure_html,
Expand Down Expand Up @@ -404,9 +409,13 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
# * 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'
Expand Down
2 changes: 1 addition & 1 deletion synapse/push/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ async def send_password_reset_mail(self, email_address, token, client_secret, si
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)
)

Expand Down
16 changes: 16 additions & 0 deletions synapse/res/templates/password_reset_confirmation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<html>
<head></head>
<body>
<!--Use a hidden form to resubmit the information necessary to reset the password-->
<form action="/_synapse/client/password_reset/{{ medium }}/submit_token" method="post">
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
<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>

15 changes: 14 additions & 1 deletion synapse/rest/client/v2_alpha/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from typing import Iterable, Pattern

from synapse.api.errors import InteractiveAuthIncompleteError
from synapse.api.urls import CLIENT_API_PREFIX
from synapse.api.urls import CLIENT_API_PREFIX, SYNAPSE_CLIENT_API_PREFIX
from synapse.types import JsonDict

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -59,6 +59,19 @@ def client_patterns(
return patterns


def synapse_client_patterns(path_regex: str) -> Iterable[Pattern]:
"""Creates a regex compiled client path with the correct synapse client
path prefix.

Args:
path_regex: The regex string to match. This should NOT have a ^
as this will be prefixed.
Returns:
An iterable of patterns.
"""
return [re.compile("^" + SYNAPSE_CLIENT_API_PREFIX + path_regex)]


def set_timeline_upper_limit(filter_json: JsonDict, filter_timeline_limit: int) -> None:
"""
Enforces a maximum limit of a timeline query.
Expand Down
94 changes: 67 additions & 27 deletions synapse/rest/client/v2_alpha/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import logging
import random
from http import HTTPStatus
from typing import TYPE_CHECKING

from synapse.api.constants import LoginType
from synapse.api.errors import (
Expand All @@ -38,7 +39,10 @@
from synapse.util.stringutils import assert_valid_client_secret, random_string
from synapse.util.threepids import canonicalise_email, check_3pid_allowed

from ._base import client_patterns, interactive_auth_handler
if TYPE_CHECKING:
from synapse.server import HomeServer

from ._base import client_patterns, interactive_auth_handler, synapse_client_patterns

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -147,46 +151,60 @@ async def on_POST(self, request):
class PasswordResetSubmitTokenServlet(RestServlet):
"""Handles 3PID validation token submission"""

PATTERNS = client_patterns(
"/password_reset/(?P<medium>[^/]*)/submit_token$", releases=(), unstable=True
)
PATTERNS = synapse_client_patterns("/password_reset/email/submit_token$")

def __init__(self, hs):
def __init__(self, hs: "HomeServer"):
"""
Args:
hs (synapse.server.HomeServer): server
hs: server
"""
super(PasswordResetSubmitTokenServlet, self).__init__()
self.hs = hs
self.auth = hs.get_auth()
self.config = hs.config
super().__init__()

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"
self._local_threepid_handling_disabled_due_to_email_config = (
hs.config.local_threepid_handling_disabled_due_to_email_config
)
self._threepid_behaviour_email = hs.config.threepid_behaviour_email
if self._threepid_behaviour_email == ThreepidBehaviour.LOCAL:
self._confirmation_email_template = (
hs.config.email_password_reset_template_confirmation_html
)
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"
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
)

async def on_GET(self, request):
self._check_threepid_behaviour()

sid = parse_string(request, "sid", required=True)
token = parse_string(request, "token", required=True)
client_secret = parse_string(request, "client_secret", required=True)
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
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,
"medium": "email",
}
respond_with_html(
request, 200, self._confirmation_email_template.render(**template_vars)
)

async def on_POST(self, request):
self._check_threepid_behaviour()

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
Expand All @@ -207,7 +225,7 @@ async def on_GET(self, request, medium):
return None

# Otherwise show the success template
html = self.config.email_password_reset_template_success_html_content
html = self._email_password_reset_template_success_html
status_code = 200
except ThreepidValidationError as e:
status_code = e.code
Expand All @@ -218,6 +236,28 @@ async def on_GET(self, request, medium):

respond_with_html(request, status_code, html)

def _check_threepid_behaviour(self):
"""
Ensure that ThreepidBehaviour is set to LOCAL, and handle cases where it is not

Raises:
SynapseError: if threepid_behaviour_email is not set to LOCAL
"""
# We currently only handle threepid token submissions for email
if self._threepid_behaviour_email == ThreepidBehaviour.OFF:
if self._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"
)
elif self._threepid_behaviour_email == ThreepidBehaviour.REMOTE:
raise SynapseError(
400,
"Password resets for this homeserver are handled by a separate program",
)


class PasswordRestServlet(RestServlet):
PATTERNS = client_patterns("/account/password$")
Expand Down
24 changes: 23 additions & 1 deletion tests/rest/client/v2_alpha/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
# 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 json
import os
import re
from email.parser import Parser
from urllib.parse import urlencode

import pkg_resources

Expand Down Expand Up @@ -250,10 +250,32 @@ def _validate_token(self, link):
# 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)
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,
)
self.render(request)
self.assertEquals(200, channel.code, channel.result)

def _get_link_from_email(self):
assert self.email_attempts, "No emails have been sent"

Expand Down
15 changes: 13 additions & 2 deletions tests/server.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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:
Expand All @@ -195,7 +200,13 @@ def make_request(
)

if content:
req.requestHeaders.addRawHeader(b"Content-Type", b"application/json")
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")

Expand Down
Loading