From 6d54f1534a693846e8972ff3eab7e06f92db4b88 Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Mon, 20 May 2019 19:42:39 +0100 Subject: [PATCH 1/7] First implementation of MSC2000 --- synapse/api/errors.py | 22 ++- synapse/config/password.py | 18 ++- synapse/handlers/password_policy.py | 74 +++++++++ synapse/handlers/set_password.py | 6 +- synapse/rest/__init__.py | 2 + .../rest/client/v2_alpha/password_policy.py | 60 +++++++ synapse/rest/client/v2_alpha/register.py | 7 +- synapse/server.py | 7 + .../client/v2_alpha/test_password_policy.py | 146 ++++++++++++++++++ 9 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 synapse/handlers/password_policy.py create mode 100644 synapse/rest/client/v2_alpha/password_policy.py create mode 100644 tests/rest/client/v2_alpha/test_password_policy.py diff --git a/synapse/api/errors.py b/synapse/api/errors.py index ff89259dece3..22e0fcfa831f 100644 --- a/synapse/api/errors.py +++ b/synapse/api/errors.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Copyright 2014-2016 OpenMarket Ltd -# Copyright 2018 New Vector Ltd +# Copyright 2017-2018 New Vector Ltd +# Copyright 2019 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. @@ -61,6 +62,13 @@ class Codes(object): INCOMPATIBLE_ROOM_VERSION = "M_INCOMPATIBLE_ROOM_VERSION" WRONG_ROOM_KEYS_VERSION = "M_WRONG_ROOM_KEYS_VERSION" EXPIRED_ACCOUNT = "ORG_MATRIX_EXPIRED_ACCOUNT" + PASSWORD_TOO_SHORT = "M_PASSWORD_TOO_SHORT" + PASSWORD_NO_DIGIT = "M_PASSWORD_NO_DIGIT" + PASSWORD_NO_UPPERCASE = "M_PASSWORD_NO_UPPERCASE" + PASSWORD_NO_LOWERCASE = "M_PASSWORD_NO_LOWERCASE" + PASSWORD_NO_SYMBOL = "M_PASSWORD_NO_SYMBOL" + PASSWORD_IN_DICTIONARY = "M_PASSWORD_IN_DICTIONARY" + WEAK_PASSWORD = "M_WEAK_PASSWORD" class CodeMessageException(RuntimeError): @@ -349,6 +357,18 @@ def error_dict(self): ) +class PasswordRefusedError(SynapseError): + """A password has been refused, either during password reset/change or registration. + """ + + def __init__(self, errcode=Codes.WEAK_PASSWORD): + super(PasswordRefusedError, self).__init__( + code=400, + msg="This password doesn't comply with the server's policy", + errcode=errcode, + ) + + class RequestSendFailed(RuntimeError): """Sending a HTTP request over federation failed due to not being able to talk to the remote server for some reason. diff --git a/synapse/config/password.py b/synapse/config/password.py index eea59e772ba8..19817110a93f 100644 --- a/synapse/config/password.py +++ b/synapse/config/password.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- -# Copyright 2015, 2016 OpenMarket Ltd +# Copyright 2015-2016 OpenMarket Ltd +# Copyright 2017-2018 New Vector Ltd +# Copyright 2019 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. @@ -28,6 +30,10 @@ def read_config(self, config): self.password_enabled = password_config.get("enabled", True) self.password_pepper = password_config.get("pepper", "") + # Password policy + self.password_policy = password_config.get("policy", {}) + self.password_policy_enabled = self.password_policy.pop("enabled", False) + def default_config(self, config_dir_path, server_name, **kwargs): return """\ password_config: @@ -39,4 +45,14 @@ def default_config(self, config_dir_path, server_name, **kwargs): # DO NOT CHANGE THIS AFTER INITIAL SETUP! # #pepper: "EVEN_MORE_SECRET" + + # Password policy. + # + #policy: + # enabled: true + # minimum_length: 15 + # require_digit: true + # require_symbol: true + # require_lowercase: true + # require_uppercase: true """ diff --git a/synapse/handlers/password_policy.py b/synapse/handlers/password_policy.py new file mode 100644 index 000000000000..10e6360ecb59 --- /dev/null +++ b/synapse/handlers/password_policy.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 New Vector Ltd +# Copyright 2019 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 +import re + +from synapse.api.errors import Codes, PasswordRefusedError + +logger = logging.getLogger(__name__) + + +class PasswordPolicyHandler(object): + def __init__(self, hs): + self.policy = hs.config.password_policy + self.enabled = hs.config.password_policy_enabled + + # Regexps for the spec'd policy parameters. + self.regexp_digit = re.compile("[0-9]") + self.regexp_symbol = re.compile("[^a-zA-Z0-9]") + self.regexp_uppercase = re.compile("[A-Z]") + self.regexp_lowercase = re.compile("[a-z]") + + def validate_password(self, password): + """Checks whether a given password complies with the server's policy. + + Args: + password (str): The password to check against the server's policy. + + Raises: + PasswordRefusedError: The password doesn't comply with the server's policy. + """ + + if not self.enabled: + return + + if len(password) < self.policy.get("minimum_length", 0): + raise PasswordRefusedError(Codes.PASSWORD_TOO_SHORT) + + if ( + self.policy.get("require_digit", False) and + self.regexp_digit.search(password) is None + ): + raise PasswordRefusedError(Codes.PASSWORD_NO_DIGIT) + + if ( + self.policy.get("require_symbol", False) and + self.regexp_symbol.search(password) is None + ): + raise PasswordRefusedError(Codes.PASSWORD_NO_SYMBOL) + + if ( + self.policy.get("require_uppercase", False) and + self.regexp_uppercase.search(password) is None + ): + raise PasswordRefusedError(Codes.PASSWORD_NO_UPPERCASE) + + if ( + self.policy.get("require_lowercase", False) and + self.regexp_lowercase.search(password) is None + ): + raise PasswordRefusedError(Codes.PASSWORD_NO_LOWERCASE) diff --git a/synapse/handlers/set_password.py b/synapse/handlers/set_password.py index 7ecdede4dc00..b556d2317351 100644 --- a/synapse/handlers/set_password.py +++ b/synapse/handlers/set_password.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright 2017 New Vector Ltd +# Copyright 2017-2018 New Vector Ltd +# Copyright 2019 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. @@ -29,9 +30,12 @@ def __init__(self, hs): super(SetPasswordHandler, self).__init__(hs) self._auth_handler = hs.get_auth_handler() self._device_handler = hs.get_device_handler() + self._password_policy_handler = hs.get_password_policy_handler() @defer.inlineCallbacks def set_password(self, user_id, newpassword, requester=None): + self._password_policy_handler.validate_password(newpassword) + password_hash = yield self._auth_handler.hash(newpassword) except_device_id = requester.device_id if requester else None diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py index 3a24d31d1ba7..bd828b9e96c4 100644 --- a/synapse/rest/__init__.py +++ b/synapse/rest/__init__.py @@ -41,6 +41,7 @@ keys, notifications, openid, + password_policy, read_marker, receipts, register, @@ -115,6 +116,7 @@ def register_servlets(client_resource, hs): room_upgrade_rest_servlet.register_servlets(hs, client_resource) capabilities.register_servlets(hs, client_resource) account_validity.register_servlets(hs, client_resource) + password_policy.register_servlets(hs, client_resource) # moving to /_synapse/admin synapse.rest.admin.register_servlets_for_client_rest_resource( diff --git a/synapse/rest/client/v2_alpha/password_policy.py b/synapse/rest/client/v2_alpha/password_policy.py new file mode 100644 index 000000000000..1505cff756c0 --- /dev/null +++ b/synapse/rest/client/v2_alpha/password_policy.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 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 twisted.internet import defer + +from synapse.http.servlet import RestServlet + +from ._base import client_v2_patterns + +logger = logging.getLogger(__name__) + + +class PasswordPolicyServlet(RestServlet): + PATTERNS = client_v2_patterns("/password_policy$") + + def __init__(self, hs): + """ + Args: + hs (synapse.server.HomeServer): server + """ + super(PasswordPolicyServlet, self).__init__() + + self.policy = hs.config.password_policy + self.enabled = hs.config.password_policy_enabled + + def on_GET(self, request): + if not self.enabled or not self.policy: + return (200, {}) + + policy = {} + + for param in [ + "minimum_length", + "require_digit", + "require_symbol", + "require_lowercase", + "require_uppercase", + ]: + if param in self.policy: + policy["m.%s" % param] = self.policy[param] + + return (200, policy) + + +def register_servlets(hs, http_server): + PasswordPolicyServlet(hs).register(http_server) diff --git a/synapse/rest/client/v2_alpha/register.py b/synapse/rest/client/v2_alpha/register.py index fa0cedb8d43f..b98070df6c2b 100644 --- a/synapse/rest/client/v2_alpha/register.py +++ b/synapse/rest/client/v2_alpha/register.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -# Copyright 2015 - 2016 OpenMarket Ltd -# Copyright 2017 Vector Creations Ltd +# Copyright 2015-2016 OpenMarket Ltd +# Copyright 2017-2018 New Vector Ltd +# Copyright 2019 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. @@ -200,6 +201,7 @@ def __init__(self, hs): self.room_member_handler = hs.get_room_member_handler() self.macaroon_gen = hs.get_macaroon_generator() self.ratelimiter = hs.get_registration_ratelimiter() + self.password_policy_handler = hs.get_password_policy_handler() self.clock = hs.get_clock() @interactive_auth_handler @@ -243,6 +245,7 @@ def on_POST(self, request): if (not isinstance(body['password'], string_types) or len(body['password']) > 512): raise SynapseError(400, "Invalid password") + self.password_policy_handler.validate_password(body['password']) desired_password = body["password"] desired_username = None diff --git a/synapse/server.py b/synapse/server.py index 80d40b9272bc..84e8a3a43e8c 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- # Copyright 2014-2016 OpenMarket Ltd +# Copyright 2017-2018 New Vector Ltd +# Copyright 2019 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. @@ -62,6 +64,7 @@ from synapse.handlers.initial_sync import InitialSyncHandler from synapse.handlers.message import EventCreationHandler, MessageHandler from synapse.handlers.pagination import PaginationHandler +from synapse.handlers.password_policy import PasswordPolicyHandler from synapse.handlers.presence import PresenceHandler from synapse.handlers.profile import BaseProfileHandler, MasterProfileHandler from synapse.handlers.read_marker import ReadMarkerHandler @@ -187,6 +190,7 @@ def build_DEPENDENCY(self) 'registration_handler', 'account_validity_handler', 'event_client_serializer', + 'password_policy_handler', ] REQUIRED_ON_MASTER_STARTUP = [ @@ -516,6 +520,9 @@ def build_account_validity_handler(self): def build_event_client_serializer(self): return EventClientSerializer(self) + def build_password_policy_handler(self): + return PasswordPolicyHandler(self) + def remove_pusher(self, app_id, push_key, user_id): return self.get_pusherpool().remove_pusher(app_id, push_key, user_id) diff --git a/tests/rest/client/v2_alpha/test_password_policy.py b/tests/rest/client/v2_alpha/test_password_policy.py new file mode 100644 index 000000000000..f76b3f7fa867 --- /dev/null +++ b/tests/rest/client/v2_alpha/test_password_policy.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# Copyright 2019 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 json + +from synapse.api.errors import Codes +from synapse.rest.client.v1 import login +from synapse.rest.client.v2_alpha import password_policy, register + +from tests import unittest + + +class PasswordPolicyTestCase(unittest.HomeserverTestCase): + """Tests the password policy feature and its compliance with MSC2000. + + When validating a password, Synapse does the necessary checks in this order: + + 1. Password is long enough + 2. Password contains digit(s) + 3. Password contains symbol(s) + 4. Password contains uppercase letter(s) + 5. Password contains lowercase letter(s) + + Therefore, each test in this test case that tests whether a password triggers the + right error code to be returned provides a password good enough to pass the previous + steps but not the one it's testing (nor any step that comes after). + """ + + servlets = [ + login.register_servlets, + register.register_servlets, + password_policy.register_servlets, + ] + + def make_homeserver(self, reactor, clock): + self.register_url = "/_matrix/client/r0/register" + self.policy = { + "enabled": True, + "minimum_length": 10, + "require_digit": True, + "require_symbol": True, + "require_lowercase": True, + "require_uppercase": True, + } + + config = self.default_config() + config["password_config"] = { + "policy": self.policy, + } + + hs = self.setup_test_homeserver(config=config) + return hs + + def test_get_policy(self): + """Tests if the /password_policy endpoint returns the configured policy.""" + + request, channel = self.make_request("GET", "/_matrix/client/r0/password_policy") + self.render(request) + + self.assertEqual(channel.code, 200, channel.result) + self.assertEqual(channel.json_body["m.minimum_length"], 10, channel.result) + self.assertEqual(channel.json_body["m.require_digit"], True, channel.result) + self.assertEqual(channel.json_body["m.require_symbol"], True, channel.result) + self.assertEqual(channel.json_body["m.require_lowercase"], True, channel.result) + self.assertEqual(channel.json_body["m.require_uppercase"], True, channel.result) + + def test_password_too_short(self): + request_data = json.dumps({"username": "kermit", "password": "shorty"}) + request, channel = self.make_request("POST", self.register_url, request_data) + self.render(request) + + self.assertEqual(channel.code, 400, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.PASSWORD_TOO_SHORT, + channel.result, + ) + + def test_password_no_digit(self): + request_data = json.dumps({"username": "kermit", "password": "longerpassword"}) + request, channel = self.make_request("POST", self.register_url, request_data) + self.render(request) + + self.assertEqual(channel.code, 400, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.PASSWORD_NO_DIGIT, + channel.result, + ) + + def test_password_no_symbol(self): + request_data = json.dumps({"username": "kermit", "password": "l0ngerpassword"}) + request, channel = self.make_request("POST", self.register_url, request_data) + self.render(request) + + self.assertEqual(channel.code, 400, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.PASSWORD_NO_SYMBOL, + channel.result, + ) + + def test_password_no_uppercase(self): + request_data = json.dumps({"username": "kermit", "password": "l0ngerpassword!"}) + request, channel = self.make_request("POST", self.register_url, request_data) + self.render(request) + + self.assertEqual(channel.code, 400, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.PASSWORD_NO_UPPERCASE, + channel.result, + ) + + def test_password_no_lowercase(self): + request_data = json.dumps({"username": "kermit", "password": "L0NGERPASSWORD!"}) + request, channel = self.make_request("POST", self.register_url, request_data) + self.render(request) + + self.assertEqual(channel.code, 400, channel.result) + self.assertEqual( + channel.json_body["errcode"], + Codes.PASSWORD_NO_LOWERCASE, + channel.result, + ) + + def test_password_compliant(self): + request_data = json.dumps({"username": "kermit", "password": "L0ngerpassword!"}) + request, channel = self.make_request("POST", self.register_url, request_data) + self.render(request) + + # Getting a 401 here means the password has passed validation and the server has + # responded with a list of registration flows. + self.assertEqual(channel.code, 401, channel.result) From ed2b5b77f24d79dff70717388fd7c96795bc527c Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Mon, 20 May 2019 19:44:37 +0100 Subject: [PATCH 2/7] Config and changelog --- changelog.d/5214.feature | 1 + docs/sample_config.yaml | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 changelog.d/5214.feature diff --git a/changelog.d/5214.feature b/changelog.d/5214.feature new file mode 100644 index 000000000000..6c0f15c901a0 --- /dev/null +++ b/changelog.d/5214.feature @@ -0,0 +1 @@ +Allow server admins to define and enforce a password policy (MSC2000). diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index d218aefee58d..8b8ebfa3d796 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -996,6 +996,16 @@ password_config: # #pepper: "EVEN_MORE_SECRET" + # Password policy. + # + #policy: + # enabled: true + # minimum_length: 15 + # require_digit: true + # require_symbol: true + # require_lowercase: true + # require_uppercase: true + # Enable sending emails for notification events or expiry notices From 6fbf2ae9a87309cc41e01d3c114a958c2f400f8b Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Mon, 20 May 2019 19:49:19 +0100 Subject: [PATCH 3/7] Remove unused import --- synapse/rest/client/v2_alpha/password_policy.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/synapse/rest/client/v2_alpha/password_policy.py b/synapse/rest/client/v2_alpha/password_policy.py index 1505cff756c0..d6e00de12ce9 100644 --- a/synapse/rest/client/v2_alpha/password_policy.py +++ b/synapse/rest/client/v2_alpha/password_policy.py @@ -15,8 +15,6 @@ import logging -from twisted.internet import defer - from synapse.http.servlet import RestServlet from ._base import client_v2_patterns From d9105b5ed8333117328694683ca6d02ca77883f8 Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Tue, 21 May 2019 09:55:32 +0100 Subject: [PATCH 4/7] Also test the /password client route --- .../client/v2_alpha/test_password_policy.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/rest/client/v2_alpha/test_password_policy.py b/tests/rest/client/v2_alpha/test_password_policy.py index f76b3f7fa867..c02d9dce0ecf 100644 --- a/tests/rest/client/v2_alpha/test_password_policy.py +++ b/tests/rest/client/v2_alpha/test_password_policy.py @@ -15,9 +15,11 @@ import json +from synapse.api.constants import LoginType from synapse.api.errors import Codes +from synapse.rest import admin from synapse.rest.client.v1 import login -from synapse.rest.client.v2_alpha import password_policy, register +from synapse.rest.client.v2_alpha import account, password_policy, register from tests import unittest @@ -39,9 +41,11 @@ class PasswordPolicyTestCase(unittest.HomeserverTestCase): """ servlets = [ + admin.register_servlets_for_client_rest_resource, login.register_servlets, register.register_servlets, password_policy.register_servlets, + account.register_servlets, ] def make_homeserver(self, reactor, clock): @@ -144,3 +148,32 @@ def test_password_compliant(self): # Getting a 401 here means the password has passed validation and the server has # responded with a list of registration flows. self.assertEqual(channel.code, 401, channel.result) + + def test_password_change(self): + """This doesn't test every possible use case, only that hitting /account/password + triggers the password validation code. + """ + compliant_password = "C0mpl!antpassword" + not_compliant_password = "notcompliantpassword" + + user_id = self.register_user("kermit", compliant_password) + tok = self.login("kermit", compliant_password) + + request_data = json.dumps({ + "new_password": not_compliant_password, + "auth": { + "password": compliant_password, + "type": LoginType.PASSWORD, + "user": user_id, + } + }) + request, channel = self.make_request( + "POST", + "/_matrix/client/r0/account/password", + request_data, + access_token=tok, + ) + self.render(request) + + self.assertEqual(channel.code, 400, channel.result) + self.assertEqual(channel.json_body["errcode"], Codes.PASSWORD_NO_DIGIT) From 42cea6b4373c41fa44db1cc6c202ef97e32f4a18 Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Tue, 21 May 2019 10:21:27 +0100 Subject: [PATCH 5/7] Make error messages more explicit --- synapse/api/errors.py | 8 ++++++-- synapse/handlers/password_policy.py | 31 +++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/synapse/api/errors.py b/synapse/api/errors.py index 22e0fcfa831f..e6c67acf96b2 100644 --- a/synapse/api/errors.py +++ b/synapse/api/errors.py @@ -361,10 +361,14 @@ class PasswordRefusedError(SynapseError): """A password has been refused, either during password reset/change or registration. """ - def __init__(self, errcode=Codes.WEAK_PASSWORD): + def __init__( + self, + msg="This password doesn't comply with the server's policy", + errcode=Codes.WEAK_PASSWORD, + ): super(PasswordRefusedError, self).__init__( code=400, - msg="This password doesn't comply with the server's policy", + msg=msg, errcode=errcode, ) diff --git a/synapse/handlers/password_policy.py b/synapse/handlers/password_policy.py index 10e6360ecb59..9994b44455cd 100644 --- a/synapse/handlers/password_policy.py +++ b/synapse/handlers/password_policy.py @@ -46,29 +46,48 @@ def validate_password(self, password): if not self.enabled: return - if len(password) < self.policy.get("minimum_length", 0): - raise PasswordRefusedError(Codes.PASSWORD_TOO_SHORT) + minimum_accepted_length = self.policy.get("minimum_length", 0) + if len(password) < minimum_accepted_length: + raise PasswordRefusedError( + msg=( + "The password must be at least %d characters long" + % minimum_accepted_length + ), + errcode=Codes.PASSWORD_TOO_SHORT, + ) if ( self.policy.get("require_digit", False) and self.regexp_digit.search(password) is None ): - raise PasswordRefusedError(Codes.PASSWORD_NO_DIGIT) + raise PasswordRefusedError( + msg="The password must include at least one digit", + errcode=Codes.PASSWORD_NO_DIGIT, + ) if ( self.policy.get("require_symbol", False) and self.regexp_symbol.search(password) is None ): - raise PasswordRefusedError(Codes.PASSWORD_NO_SYMBOL) + raise PasswordRefusedError( + msg="The password must include at least one symbol", + errcode=Codes.PASSWORD_NO_SYMBOL, + ) if ( self.policy.get("require_uppercase", False) and self.regexp_uppercase.search(password) is None ): - raise PasswordRefusedError(Codes.PASSWORD_NO_UPPERCASE) + raise PasswordRefusedError( + msg="The password must include at least one uppercase letter", + errcode=Codes.PASSWORD_NO_UPPERCASE, + ) if ( self.policy.get("require_lowercase", False) and self.regexp_lowercase.search(password) is None ): - raise PasswordRefusedError(Codes.PASSWORD_NO_LOWERCASE) + raise PasswordRefusedError( + msg="The password must include at least one lowercase letter", + errcode=Codes.PASSWORD_NO_LOWERCASE, + ) From 7dfc3c327c1ac36654a6957c2c19aa9ad2d4d6d0 Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Tue, 21 May 2019 10:49:44 +0100 Subject: [PATCH 6/7] Improve documentation on generated configuration --- docs/sample_config.yaml | 34 +++++++++++++++++++++++++++------- synapse/config/password.py | 34 +++++++++++++++++++++++++++------- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index 8b8ebfa3d796..0f3c0f5fd549 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -996,15 +996,35 @@ password_config: # #pepper: "EVEN_MORE_SECRET" - # Password policy. + # Define and enforce a password policy. Each parameter is optional, boolean + # parameters default to 'false' and integer parameters default to 0. + # This is an early implementation of MSC2000. # #policy: - # enabled: true - # minimum_length: 15 - # require_digit: true - # require_symbol: true - # require_lowercase: true - # require_uppercase: true + # Whether to enforce the password policy. + # + #enabled: true + + # Minimum accepted length for a password. + # + #minimum_length: 15 + + # Whether a password must contain at least one digit. + # + #require_digit: true + + # Whether a password must contain at least one symbol. + # A symbol is any character that's not a number or a letter. + # + #require_symbol: true + + # Whether a password must contain at least one lowercase letter. + # + #require_lowercase: true + + # Whether a password must contain at least one lowercase letter. + # + #require_uppercase: true diff --git a/synapse/config/password.py b/synapse/config/password.py index 19817110a93f..48a38512cbb2 100644 --- a/synapse/config/password.py +++ b/synapse/config/password.py @@ -46,13 +46,33 @@ def default_config(self, config_dir_path, server_name, **kwargs): # #pepper: "EVEN_MORE_SECRET" - # Password policy. + # Define and enforce a password policy. Each parameter is optional, boolean + # parameters default to 'false' and integer parameters default to 0. + # This is an early implementation of MSC2000. # #policy: - # enabled: true - # minimum_length: 15 - # require_digit: true - # require_symbol: true - # require_lowercase: true - # require_uppercase: true + # Whether to enforce the password policy. + # + #enabled: true + + # Minimum accepted length for a password. + # + #minimum_length: 15 + + # Whether a password must contain at least one digit. + # + #require_digit: true + + # Whether a password must contain at least one symbol. + # A symbol is any character that's not a number or a letter. + # + #require_symbol: true + + # Whether a password must contain at least one lowercase letter. + # + #require_lowercase: true + + # Whether a password must contain at least one lowercase letter. + # + #require_uppercase: true """ From 4a9eba957680fc32582687f399626f9ab7b55755 Mon Sep 17 00:00:00 2001 From: Brendan Abolivier Date: Wed, 22 May 2019 10:43:23 +0100 Subject: [PATCH 7/7] Test whole dict instead of individual fields --- tests/rest/client/v2_alpha/test_password_policy.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/rest/client/v2_alpha/test_password_policy.py b/tests/rest/client/v2_alpha/test_password_policy.py index c02d9dce0ecf..17c22fe751c5 100644 --- a/tests/rest/client/v2_alpha/test_password_policy.py +++ b/tests/rest/client/v2_alpha/test_password_policy.py @@ -74,11 +74,13 @@ def test_get_policy(self): self.render(request) self.assertEqual(channel.code, 200, channel.result) - self.assertEqual(channel.json_body["m.minimum_length"], 10, channel.result) - self.assertEqual(channel.json_body["m.require_digit"], True, channel.result) - self.assertEqual(channel.json_body["m.require_symbol"], True, channel.result) - self.assertEqual(channel.json_body["m.require_lowercase"], True, channel.result) - self.assertEqual(channel.json_body["m.require_uppercase"], True, channel.result) + self.assertEqual(channel.json_body, { + "m.minimum_length": 10, + "m.require_digit": True, + "m.require_symbol": True, + "m.require_lowercase": True, + "m.require_uppercase": True, + }, channel.result) def test_password_too_short(self): request_data = json.dumps({"username": "kermit", "password": "shorty"})