@@ -0,0 +1 @@ | |||
Improve validation of request bodies for the following client-server API endpoints: [`/account/password`](https://spec.matrix.org/v1.3/client-server-api/#post_matrixclientv3accountpassword), [`/account/password/email/requestToken`](https://spec.matrix.org/v1.3/client-server-api/#post_matrixclientv3accountpasswordemailrequesttoken), [`/account/deactivate`](https://spec.matrix.org/v1.3/client-server-api/#post_matrixclientv3accountdeactivate) and [`/account/3pid/email/requestToken`](https://spec.matrix.org/v1.3/client-server-api/#post_matrixclientv3account3pidemailrequesttoken). |
@@ -1,6 +1,6 @@ | |||
[mypy] | |||
namespace_packages = True | |||
plugins = mypy_zope:plugin, scripts-dev/mypy_synapse_plugin.py | |||
plugins = pydantic.mypy, mypy_zope:plugin, scripts-dev/mypy_synapse_plugin.py | |||
follow_imports = normal | |||
check_untyped_defs = True | |||
show_error_codes = True | |||
@@ -778,6 +778,21 @@ category = "main" | |||
optional = false | |||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" | |||
[[package]] | |||
name = "pydantic" | |||
version = "1.9.1" | |||
description = "Data validation and settings management using python type hints" | |||
category = "main" | |||
optional = false | |||
python-versions = ">=3.6.1" | |||
[package.dependencies] | |||
typing-extensions = ">=3.7.4.3" | |||
[package.extras] | |||
dotenv = ["python-dotenv (>=0.10.4)"] | |||
email = ["email-validator (>=1.0.3)"] | |||
[[package]] | |||
name = "pyflakes" | |||
version = "2.4.0" | |||
@@ -1563,7 +1578,7 @@ url_preview = ["lxml"] | |||
[metadata] | |||
lock-version = "1.1" | |||
python-versions = "^3.7.1" | |||
content-hash = "c24bbcee7e86dbbe7cdbf49f91a25b310bf21095452641e7440129f59b077f78" | |||
content-hash = "7de518bf27967b3547eab8574342cfb67f87d6b47b4145c13de11112141dbf2d" | |||
[metadata.files] | |||
attrs = [ | |||
@@ -2260,6 +2275,43 @@ pycparser = [ | |||
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, | |||
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, | |||
] | |||
pydantic = [ | |||
{file = "pydantic-1.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c8098a724c2784bf03e8070993f6d46aa2eeca031f8d8a048dff277703e6e193"}, | |||
{file = "pydantic-1.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c320c64dd876e45254bdd350f0179da737463eea41c43bacbee9d8c9d1021f11"}, | |||
{file = "pydantic-1.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18f3e912f9ad1bdec27fb06b8198a2ccc32f201e24174cec1b3424dda605a310"}, | |||
{file = "pydantic-1.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c11951b404e08b01b151222a1cb1a9f0a860a8153ce8334149ab9199cd198131"}, | |||
{file = "pydantic-1.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8bc541a405423ce0e51c19f637050acdbdf8feca34150e0d17f675e72d119580"}, | |||
{file = "pydantic-1.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e565a785233c2d03724c4dc55464559639b1ba9ecf091288dd47ad9c629433bd"}, | |||
{file = "pydantic-1.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a4a88dcd6ff8fd47c18b3a3709a89adb39a6373f4482e04c1b765045c7e282fd"}, | |||
{file = "pydantic-1.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:447d5521575f18e18240906beadc58551e97ec98142266e521c34968c76c8761"}, | |||
{file = "pydantic-1.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:985ceb5d0a86fcaa61e45781e567a59baa0da292d5ed2e490d612d0de5796918"}, | |||
{file = "pydantic-1.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059b6c1795170809103a1538255883e1983e5b831faea6558ef873d4955b4a74"}, | |||
{file = "pydantic-1.9.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:d12f96b5b64bec3f43c8e82b4aab7599d0157f11c798c9f9c528a72b9e0b339a"}, | |||
{file = "pydantic-1.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:ae72f8098acb368d877b210ebe02ba12585e77bd0db78ac04a1ee9b9f5dd2166"}, | |||
{file = "pydantic-1.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:79b485767c13788ee314669008d01f9ef3bc05db9ea3298f6a50d3ef596a154b"}, | |||
{file = "pydantic-1.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:494f7c8537f0c02b740c229af4cb47c0d39840b829ecdcfc93d91dcbb0779892"}, | |||
{file = "pydantic-1.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0f047e11febe5c3198ed346b507e1d010330d56ad615a7e0a89fae604065a0e"}, | |||
{file = "pydantic-1.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:969dd06110cb780da01336b281f53e2e7eb3a482831df441fb65dd30403f4608"}, | |||
{file = "pydantic-1.9.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:177071dfc0df6248fd22b43036f936cfe2508077a72af0933d0c1fa269b18537"}, | |||
{file = "pydantic-1.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9bcf8b6e011be08fb729d110f3e22e654a50f8a826b0575c7196616780683380"}, | |||
{file = "pydantic-1.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a955260d47f03df08acf45689bd163ed9df82c0e0124beb4251b1290fa7ae728"}, | |||
{file = "pydantic-1.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9ce157d979f742a915b75f792dbd6aa63b8eccaf46a1005ba03aa8a986bde34a"}, | |||
{file = "pydantic-1.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0bf07cab5b279859c253d26a9194a8906e6f4a210063b84b433cf90a569de0c1"}, | |||
{file = "pydantic-1.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d93d4e95eacd313d2c765ebe40d49ca9dd2ed90e5b37d0d421c597af830c195"}, | |||
{file = "pydantic-1.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1542636a39c4892c4f4fa6270696902acb186a9aaeac6f6cf92ce6ae2e88564b"}, | |||
{file = "pydantic-1.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a9af62e9b5b9bc67b2a195ebc2c2662fdf498a822d62f902bf27cccb52dbbf49"}, | |||
{file = "pydantic-1.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fe4670cb32ea98ffbf5a1262f14c3e102cccd92b1869df3bb09538158ba90fe6"}, | |||
{file = "pydantic-1.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:9f659a5ee95c8baa2436d392267988fd0f43eb774e5eb8739252e5a7e9cf07e0"}, | |||
{file = "pydantic-1.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b83ba3825bc91dfa989d4eed76865e71aea3a6ca1388b59fc801ee04c4d8d0d6"}, | |||
{file = "pydantic-1.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1dd8fecbad028cd89d04a46688d2fcc14423e8a196d5b0a5c65105664901f810"}, | |||
{file = "pydantic-1.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02eefd7087268b711a3ff4db528e9916ac9aa18616da7bca69c1871d0b7a091f"}, | |||
{file = "pydantic-1.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7eb57ba90929bac0b6cc2af2373893d80ac559adda6933e562dcfb375029acee"}, | |||
{file = "pydantic-1.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4ce9ae9e91f46c344bec3b03d6ee9612802682c1551aaf627ad24045ce090761"}, | |||
{file = "pydantic-1.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:72ccb318bf0c9ab97fc04c10c37683d9eea952ed526707fabf9ac5ae59b701fd"}, | |||
{file = "pydantic-1.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:61b6760b08b7c395975d893e0b814a11cf011ebb24f7d869e7118f5a339a82e1"}, | |||
{file = "pydantic-1.9.1-py3-none-any.whl", hash = "sha256:4988c0f13c42bfa9ddd2fe2f569c9d54646ce84adc5de84228cfe83396f3bd58"}, | |||
{file = "pydantic-1.9.1.tar.gz", hash = "sha256:1ed987c3ff29fff7fd8c3ea3a3ea877ad310aae2ef9889a119e22d3f2db0691a"}, | |||
] | |||
pyflakes = [ | |||
{file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, | |||
{file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, | |||
@@ -158,6 +158,9 @@ packaging = ">=16.1" | |||
# At the time of writing, we only use functions from the version `importlib.metadata` | |||
# which shipped in Python 3.8. This corresponds to version 1.4 of the backport. | |||
importlib_metadata = { version = ">=1.4", python = "<3.8" } | |||
# This is the most recent version of Pydantic with available on common distros. | |||
pydantic = ">=1.7.4" | |||
# Optional Dependencies | |||
@@ -23,9 +23,12 @@ from typing import ( | |||
Optional, | |||
Sequence, | |||
Tuple, | |||
Type, | |||
TypeVar, | |||
overload, | |||
) | |||
from pydantic import BaseModel, ValidationError | |||
from typing_extensions import Literal | |||
from twisted.web.server import Request | |||
@@ -694,6 +697,28 @@ def parse_json_object_from_request( | |||
return content | |||
Model = TypeVar("Model", bound=BaseModel) | |||
def parse_and_validate_json_object_from_request( | |||
request: Request, model_type: Type[Model] | |||
) -> Model: | |||
"""Parse a JSON object from the body of a twisted HTTP request, then deserialise and | |||
validate using the given pydantic model. | |||
Raises: | |||
SynapseError if the request body couldn't be decoded as JSON or | |||
if it wasn't a JSON object. | |||
""" | |||
content = parse_json_object_from_request(request, allow_empty_body=False) | |||
try: | |||
instance = model_type.parse_obj(content) | |||
except ValidationError as e: | |||
raise SynapseError(HTTPStatus.BAD_REQUEST, str(e), errcode=Codes.BAD_JSON) | |||
return instance | |||
def assert_params_in_dict(body: JsonDict, required: Iterable[str]) -> None: | |||
absent = [] | |||
for k in required: | |||
@@ -15,10 +15,11 @@ | |||
# limitations under the License. | |||
import logging | |||
import random | |||
from http import HTTPStatus | |||
from typing import TYPE_CHECKING, Optional, Tuple | |||
from urllib.parse import urlparse | |||
from pydantic import StrictBool, StrictStr, constr | |||
from twisted.web.server import Request | |||
from synapse.api.constants import LoginType | |||
@@ -34,12 +35,15 @@ from synapse.http.server import HttpServer, finish_request, respond_with_html | |||
from synapse.http.servlet import ( | |||
RestServlet, | |||
assert_params_in_dict, | |||
parse_and_validate_json_object_from_request, | |||
parse_json_object_from_request, | |||
parse_string, | |||
) | |||
from synapse.http.site import SynapseRequest | |||
from synapse.metrics import threepid_send_requests | |||
from synapse.push.mailer import Mailer | |||
from synapse.rest.client.models import AuthenticationData, EmailRequestTokenBody | |||
from synapse.rest.models import RequestBodyModel | |||
from synapse.types import JsonDict | |||
from synapse.util.msisdn import phone_number_to_msisdn | |||
from synapse.util.stringutils import assert_valid_client_secret, random_string | |||
@@ -82,32 +86,16 @@ class EmailPasswordRequestTokenRestServlet(RestServlet): | |||
400, "Email-based password resets have been disabled on this server" | |||
) | |||
body = parse_json_object_from_request(request) | |||
assert_params_in_dict(body, ["client_secret", "email", "send_attempt"]) | |||
# Extract params from body | |||
client_secret = body["client_secret"] | |||
assert_valid_client_secret(client_secret) | |||
# Canonicalise the email address. The addresses are all stored canonicalised | |||
# in the database. This allows the user to reset his password without having to | |||
# know the exact spelling (eg. upper and lower case) of address in the database. | |||
# Stored in the database "foo@bar.com" | |||
# User requests with "FOO@bar.com" would raise a Not Found error | |||
try: | |||
email = validate_email(body["email"]) | |||
except ValueError as e: | |||
raise SynapseError(400, str(e)) | |||
send_attempt = body["send_attempt"] | |||
next_link = body.get("next_link") # Optional param | |||
body = parse_and_validate_json_object_from_request( | |||
request, EmailRequestTokenBody | |||
) | |||
if next_link: | |||
if body.next_link: | |||
# Raise if the provided next_link value isn't valid | |||
assert_valid_next_link(self.hs, next_link) | |||
assert_valid_next_link(self.hs, body.next_link) | |||
await self.identity_handler.ratelimit_request_token_requests( | |||
request, "email", email | |||
request, "email", body.email | |||
) | |||
# The email will be sent to the stored address. | |||
@@ -115,7 +103,7 @@ class EmailPasswordRequestTokenRestServlet(RestServlet): | |||
# an email address which is controlled by the attacker but which, after | |||
# canonicalisation, matches the one in our database. | |||
existing_user_id = await self.hs.get_datastores().main.get_user_id_by_threepid( | |||
"email", email | |||
"email", body.email | |||
) | |||
if existing_user_id is None: | |||
@@ -135,26 +123,26 @@ class EmailPasswordRequestTokenRestServlet(RestServlet): | |||
# Have the configured identity server handle the request | |||
ret = await self.identity_handler.request_email_token( | |||
self.hs.config.registration.account_threepid_delegate_email, | |||
email, | |||
client_secret, | |||
send_attempt, | |||
next_link, | |||
body.email, | |||
body.client_secret, | |||
body.send_attempt, | |||
body.next_link, | |||
) | |||
else: | |||
# Send password reset emails from Synapse | |||
sid = await self.identity_handler.send_threepid_validation( | |||
email, | |||
client_secret, | |||
send_attempt, | |||
body.email, | |||
body.client_secret, | |||
body.send_attempt, | |||
self.mailer.send_password_reset_mail, | |||
next_link, | |||
body.next_link, | |||
) | |||
# Wrap the session id in a JSON object | |||
ret = {"sid": sid} | |||
threepid_send_requests.labels(type="email", reason="password_reset").observe( | |||
send_attempt | |||
body.send_attempt | |||
) | |||
return 200, ret | |||
@@ -172,16 +160,23 @@ class PasswordRestServlet(RestServlet): | |||
self.password_policy_handler = hs.get_password_policy_handler() | |||
self._set_password_handler = hs.get_set_password_handler() | |||
class PostBody(RequestBodyModel): | |||
auth: Optional[AuthenticationData] = None | |||
logout_devices: StrictBool = True | |||
if TYPE_CHECKING: | |||
# workaround for https://github.com/samuelcolvin/pydantic/issues/156 | |||
new_password: Optional[StrictStr] = None | |||
else: | |||
new_password: Optional[constr(max_length=512, strict=True)] = None | |||
@interactive_auth_handler | |||
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: | |||
body = parse_json_object_from_request(request) | |||
body = parse_and_validate_json_object_from_request(request, self.PostBody) | |||
# we do basic sanity checks here because the auth layer will store these | |||
# in sessions. Pull out the new password provided to us. | |||
new_password = body.pop("new_password", None) | |||
new_password = body.new_password | |||
if new_password is not None: | |||
if not isinstance(new_password, str) or len(new_password) > 512: | |||
raise SynapseError(400, "Invalid password") | |||
self.password_policy_handler.validate_password(new_password) | |||
# there are two possibilities here. Either the user does not have an | |||
@@ -201,7 +196,7 @@ class PasswordRestServlet(RestServlet): | |||
params, session_id = await self.auth_handler.validate_user_via_ui_auth( | |||
requester, | |||
request, | |||
body, | |||
body.dict(), | |||
"modify your account password", | |||
) | |||
except InteractiveAuthIncompleteError as e: | |||
@@ -224,7 +219,7 @@ class PasswordRestServlet(RestServlet): | |||
result, params, session_id = await self.auth_handler.check_ui_auth( | |||
[[LoginType.EMAIL_IDENTITY]], | |||
request, | |||
body, | |||
body.dict(), | |||
"modify your account password", | |||
) | |||
except InteractiveAuthIncompleteError as e: | |||
@@ -299,37 +294,33 @@ class DeactivateAccountRestServlet(RestServlet): | |||
self.auth_handler = hs.get_auth_handler() | |||
self._deactivate_account_handler = hs.get_deactivate_account_handler() | |||
class PostBody(RequestBodyModel): | |||
auth: Optional[AuthenticationData] = None | |||
id_server: Optional[StrictStr] = None | |||
# Not specced, see https://github.com/matrix-org/matrix-spec/issues/297 | |||
erase: StrictBool = False | |||
@interactive_auth_handler | |||
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: | |||
body = parse_json_object_from_request(request) | |||
erase = body.get("erase", False) | |||
if not isinstance(erase, bool): | |||
raise SynapseError( | |||
HTTPStatus.BAD_REQUEST, | |||
"Param 'erase' must be a boolean, if given", | |||
Codes.BAD_JSON, | |||
) | |||
body = parse_and_validate_json_object_from_request(request, self.PostBody) | |||
requester = await self.auth.get_user_by_req(request) | |||
# allow ASes to deactivate their own users | |||
if requester.app_service: | |||
await self._deactivate_account_handler.deactivate_account( | |||
requester.user.to_string(), erase, requester | |||
requester.user.to_string(), body.erase, requester | |||
) | |||
return 200, {} | |||
await self.auth_handler.validate_user_via_ui_auth( | |||
requester, | |||
request, | |||
body, | |||
body.dict(), | |||
"deactivate your account", | |||
) | |||
result = await self._deactivate_account_handler.deactivate_account( | |||
requester.user.to_string(), | |||
erase, | |||
requester, | |||
id_server=body.get("id_server"), | |||
requester.user.to_string(), body.erase, requester, id_server=body.id_server | |||
) | |||
if result: | |||
id_server_unbind_result = "success" | |||
@@ -364,28 +355,15 @@ class EmailThreepidRequestTokenRestServlet(RestServlet): | |||
"Adding emails have been disabled due to lack of an email config" | |||
) | |||
raise SynapseError( | |||
400, "Adding an email to your account is disabled on this server" | |||
400, | |||
"Adding an email to your account is disabled on this server", | |||
) | |||
body = parse_json_object_from_request(request) | |||
assert_params_in_dict(body, ["client_secret", "email", "send_attempt"]) | |||
client_secret = body["client_secret"] | |||
assert_valid_client_secret(client_secret) | |||
# Canonicalise the email address. The addresses are all stored canonicalised | |||
# in the database. | |||
# This ensures that the validation email is sent to the canonicalised address | |||
# as it will later be entered into the database. | |||
# Otherwise the email will be sent to "FOO@bar.com" and stored as | |||
# "foo@bar.com" in database. | |||
try: | |||
email = validate_email(body["email"]) | |||
except ValueError as e: | |||
raise SynapseError(400, str(e)) | |||
send_attempt = body["send_attempt"] | |||
next_link = body.get("next_link") # Optional param | |||
body = parse_and_validate_json_object_from_request( | |||
request, EmailRequestTokenBody | |||
) | |||
if not await check_3pid_allowed(self.hs, "email", email): | |||
if not await check_3pid_allowed(self.hs, "email", body.email): | |||
raise SynapseError( | |||
403, | |||
"Your email domain is not authorized on this server", | |||
@@ -393,14 +371,14 @@ class EmailThreepidRequestTokenRestServlet(RestServlet): | |||
) | |||
await self.identity_handler.ratelimit_request_token_requests( | |||
request, "email", email | |||
request, "email", body.email | |||
) | |||
if next_link: | |||
if body.next_link: | |||
# Raise if the provided next_link value isn't valid | |||
assert_valid_next_link(self.hs, next_link) | |||
assert_valid_next_link(self.hs, body.next_link) | |||
existing_user_id = await self.store.get_user_id_by_threepid("email", email) | |||
existing_user_id = await self.store.get_user_id_by_threepid("email", body.email) | |||
if existing_user_id is not None: | |||
if self.config.server.request_token_inhibit_3pid_errors: | |||
@@ -419,26 +397,26 @@ class EmailThreepidRequestTokenRestServlet(RestServlet): | |||
# Have the configured identity server handle the request | |||
ret = await self.identity_handler.request_email_token( | |||
self.hs.config.registration.account_threepid_delegate_email, | |||
email, | |||
client_secret, | |||
send_attempt, | |||
next_link, | |||
body.email, | |||
body.client_secret, | |||
body.send_attempt, | |||
body.next_link, | |||
) | |||
else: | |||
# Send threepid validation emails from Synapse | |||
sid = await self.identity_handler.send_threepid_validation( | |||
email, | |||
client_secret, | |||
send_attempt, | |||
body.email, | |||
body.client_secret, | |||
body.send_attempt, | |||
self.mailer.send_add_threepid_mail, | |||
next_link, | |||
body.next_link, | |||
) | |||
# Wrap the session id in a JSON object | |||
ret = {"sid": sid} | |||
threepid_send_requests.labels(type="email", reason="add_threepid").observe( | |||
send_attempt | |||
body.send_attempt | |||
) | |||
return 200, ret | |||
@@ -0,0 +1,69 @@ | |||
# Copyright 2022 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. | |||
from typing import TYPE_CHECKING, Dict, Optional | |||
from pydantic import Extra, StrictInt, StrictStr, constr, validator | |||
from synapse.rest.models import RequestBodyModel | |||
from synapse.util.threepids import validate_email | |||
class AuthenticationData(RequestBodyModel): | |||
""" | |||
Data used during user-interactive authentication. | |||
(The name "Authentication Data" is taken directly from the spec.) | |||
Additional keys will be present, depending on the `type` field. Use `.dict()` to | |||
access them. | |||
""" | |||
class Config: | |||
extra = Extra.allow | |||
session: Optional[StrictStr] = None | |||
type: Optional[StrictStr] = None | |||
class EmailRequestTokenBody(RequestBodyModel): | |||
if TYPE_CHECKING: | |||
client_secret: StrictStr | |||
else: | |||
# See also assert_valid_client_secret() | |||
client_secret: constr( | |||
regex="[0-9a-zA-Z.=_-]", # noqa: F722 | |||
min_length=0, | |||
max_length=255, | |||
strict=True, | |||
) | |||
email: StrictStr | |||
id_server: Optional[StrictStr] | |||
id_access_token: Optional[StrictStr] | |||
next_link: Optional[StrictStr] | |||
send_attempt: StrictInt | |||
@validator("id_access_token", always=True) | |||
def token_required_for_identity_server( | |||
cls, token: Optional[str], values: Dict[str, object] | |||
) -> Optional[str]: | |||
if values.get("id_server") is not None and token is None: | |||
raise ValueError("id_access_token is required if an id_server is supplied.") | |||
return token | |||
# Canonicalise the email address. The addresses are all stored canonicalised | |||
# in the database. This allows the user to reset his password without having to | |||
# know the exact spelling (eg. upper and lower case) of address in the database. | |||
# Without this, an email stored in the database as "foo@bar.com" would cause | |||
# user requests for "FOO@bar.com" to raise a Not Found error. | |||
_email_validator = validator("email", allow_reuse=True)(validate_email) |
@@ -0,0 +1,23 @@ | |||
from pydantic import BaseModel, Extra | |||
class RequestBodyModel(BaseModel): | |||
"""A custom version of Pydantic's BaseModel which | |||
- ignores unknown fields and | |||
- does not allow fields to be overwritten after construction, | |||
but otherwise uses Pydantic's default behaviour. | |||
Ignoring unknown fields is a useful default. It means that clients can provide | |||
unstable field not known to the server without the request being refused outright. | |||
Subclassing in this way is recommended by | |||
https://pydantic-docs.helpmanual.io/usage/model_config/#change-behaviour-globally | |||
""" | |||
class Config: | |||
# By default, ignore fields that we don't recognise. | |||
extra = Extra.ignore | |||
# By default, don't allow fields to be reassigned after parsing. | |||
allow_mutation = False |
@@ -488,7 +488,7 @@ class DeactivateTestCase(unittest.HomeserverTestCase): | |||
channel = self.make_request( | |||
"POST", "account/deactivate", request_data, access_token=tok | |||
) | |||
self.assertEqual(channel.code, 200) | |||
self.assertEqual(channel.code, 200, channel.json_body) | |||
class WhoamiTestCase(unittest.HomeserverTestCase): | |||
@@ -641,21 +641,21 @@ class ThreepidEmailRestTestCase(unittest.HomeserverTestCase): | |||
def test_add_email_no_at(self) -> None: | |||
self._request_token_invalid_email( | |||
"address-without-at.bar", | |||
expected_errcode=Codes.UNKNOWN, | |||
expected_errcode=Codes.BAD_JSON, | |||
expected_error="Unable to parse email address", | |||
) | |||
def test_add_email_two_at(self) -> None: | |||
self._request_token_invalid_email( | |||
"foo@foo@test.bar", | |||
expected_errcode=Codes.UNKNOWN, | |||
expected_errcode=Codes.BAD_JSON, | |||
expected_error="Unable to parse email address", | |||
) | |||
def test_add_email_bad_format(self) -> None: | |||
self._request_token_invalid_email( | |||
"user@bad.example.net@good.example.com", | |||
expected_errcode=Codes.UNKNOWN, | |||
expected_errcode=Codes.BAD_JSON, | |||
expected_error="Unable to parse email address", | |||
) | |||
@@ -1001,7 +1001,7 @@ class ThreepidEmailRestTestCase(unittest.HomeserverTestCase): | |||
HTTPStatus.BAD_REQUEST, channel.code, msg=channel.result["body"] | |||
) | |||
self.assertEqual(expected_errcode, channel.json_body["errcode"]) | |||
self.assertEqual(expected_error, channel.json_body["error"]) | |||
self.assertIn(expected_error, channel.json_body["error"]) | |||
def _validate_token(self, link: str) -> None: | |||
# Remove the host | |||
@@ -0,0 +1,53 @@ | |||
# Copyright 2022 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 unittest | |||
from pydantic import ValidationError | |||
from synapse.rest.client.models import EmailRequestTokenBody | |||
class EmailRequestTokenBodyTestCase(unittest.TestCase): | |||
base_request = { | |||
"client_secret": "hunter2", | |||
"email": "alice@wonderland.com", | |||
"send_attempt": 1, | |||
} | |||
def test_token_required_if_id_server_provided(self) -> None: | |||
with self.assertRaises(ValidationError): | |||
EmailRequestTokenBody.parse_obj( | |||
{ | |||
**self.base_request, | |||
"id_server": "identity.wonderland.com", | |||
} | |||
) | |||
with self.assertRaises(ValidationError): | |||
EmailRequestTokenBody.parse_obj( | |||
{ | |||
**self.base_request, | |||
"id_server": "identity.wonderland.com", | |||
"id_access_token": None, | |||
} | |||
) | |||
def test_token_typechecked_when_id_server_provided(self) -> None: | |||
with self.assertRaises(ValidationError): | |||
EmailRequestTokenBody.parse_obj( | |||
{ | |||
**self.base_request, | |||
"id_server": "identity.wonderland.com", | |||
"id_access_token": 1337, | |||
} | |||
) |