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

Commit

Permalink
Allow server admins to define and enforce a password policy (MSC2000). (
Browse files Browse the repository at this point in the history
  • Loading branch information
dklimpel authored Mar 26, 2020
1 parent 1c1242a commit e8e2ddb
Showing 11 changed files with 437 additions and 0 deletions.
1 change: 1 addition & 0 deletions changelog.d/7118.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow server admins to define and enforce a password policy (MSC2000).
35 changes: 35 additions & 0 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
@@ -1482,6 +1482,41 @@ password_config:
#
#pepper: "EVEN_MORE_SECRET"

# Define and enforce a password policy. Each parameter is optional.
# This is an implementation of MSC2000.
#
policy:
# Whether to enforce the password policy.
# Defaults to 'false'.
#
#enabled: true

# Minimum accepted length for a password.
# Defaults to 0.
#
#minimum_length: 15

# Whether a password must contain at least one digit.
# Defaults to 'false'.
#
#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.
# Defaults to 'false'.
#
#require_symbol: true

# Whether a password must contain at least one lowercase letter.
# Defaults to 'false'.
#
#require_lowercase: true

# Whether a password must contain at least one lowercase letter.
# Defaults to 'false'.
#
#require_uppercase: true


# Configuration for sending emails from Synapse.
#
21 changes: 21 additions & 0 deletions synapse/api/errors.py
Original file line number Diff line number Diff line change
@@ -64,6 +64,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"
INVALID_SIGNATURE = "M_INVALID_SIGNATURE"
USER_DEACTIVATED = "M_USER_DEACTIVATED"
BAD_ALIAS = "M_BAD_ALIAS"
@@ -439,6 +446,20 @@ def error_dict(self):
return cs_error(self.msg, self.errcode, room_version=self._room_version)


class PasswordRefusedError(SynapseError):
"""A password has been refused, either during password reset/change or registration.
"""

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=msg, 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.
39 changes: 39 additions & 0 deletions synapse/config/password.py
Original file line number Diff line number Diff line change
@@ -31,6 +31,10 @@ def read_config(self, config, **kwargs):
self.password_localdb_enabled = password_config.get("localdb_enabled", True)
self.password_pepper = password_config.get("pepper", "")

# Password policy
self.password_policy = password_config.get("policy") or {}
self.password_policy_enabled = self.password_policy.get("enabled", False)

def generate_config_section(self, config_dir_path, server_name, **kwargs):
return """\
password_config:
@@ -48,4 +52,39 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
# DO NOT CHANGE THIS AFTER INITIAL SETUP!
#
#pepper: "EVEN_MORE_SECRET"
# Define and enforce a password policy. Each parameter is optional.
# This is an implementation of MSC2000.
#
policy:
# Whether to enforce the password policy.
# Defaults to 'false'.
#
#enabled: true
# Minimum accepted length for a password.
# Defaults to 0.
#
#minimum_length: 15
# Whether a password must contain at least one digit.
# Defaults to 'false'.
#
#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.
# Defaults to 'false'.
#
#require_symbol: true
# Whether a password must contain at least one lowercase letter.
# Defaults to 'false'.
#
#require_lowercase: true
# Whether a password must contain at least one lowercase letter.
# Defaults to 'false'.
#
#require_uppercase: true
"""
93 changes: 93 additions & 0 deletions synapse/handlers/password_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# -*- 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

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(
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(
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(
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(
msg="The password must include at least one lowercase letter",
errcode=Codes.PASSWORD_NO_LOWERCASE,
)
2 changes: 2 additions & 0 deletions synapse/handlers/set_password.py
Original file line number Diff line number Diff line change
@@ -32,6 +32,7 @@ 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(
@@ -44,6 +45,7 @@ def set_password(
if not self.hs.config.password_localdb_enabled:
raise SynapseError(403, "Password change disabled", errcode=Codes.FORBIDDEN)

self._password_policy_handler.validate_password(new_password)
password_hash = yield self._auth_handler.hash(new_password)

try:
2 changes: 2 additions & 0 deletions synapse/rest/__init__.py
Original file line number Diff line number Diff line change
@@ -41,6 +41,7 @@
keys,
notifications,
openid,
password_policy,
read_marker,
receipts,
register,
@@ -118,6 +119,7 @@ def register_servlets(client_resource, hs):
capabilities.register_servlets(hs, client_resource)
account_validity.register_servlets(hs, client_resource)
relations.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(
58 changes: 58 additions & 0 deletions synapse/rest/client/v2_alpha/password_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# -*- 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 synapse.http.servlet import RestServlet

from ._base import client_patterns

logger = logging.getLogger(__name__)


class PasswordPolicyServlet(RestServlet):
PATTERNS = client_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)
2 changes: 2 additions & 0 deletions synapse/rest/client/v2_alpha/register.py
Original file line number Diff line number Diff line change
@@ -373,6 +373,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()

self._registration_flows = _calculate_registration_flows(
@@ -420,6 +421,7 @@ async def on_POST(self, request):
or len(body["password"]) > 512
):
raise SynapseError(400, "Invalid password")
self.password_policy_handler.validate_password(body["password"])

desired_username = None
if "username" in body:
5 changes: 5 additions & 0 deletions synapse/server.py
Original file line number Diff line number Diff line change
@@ -66,6 +66,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
@@ -199,6 +200,7 @@ def build_DEPENDENCY(self)
"account_validity_handler",
"saml_handler",
"event_client_serializer",
"password_policy_handler",
"storage",
"replication_streamer",
]
@@ -535,6 +537,9 @@ def build_saml_handler(self):
def build_event_client_serializer(self):
return EventClientSerializer(self)

def build_password_policy_handler(self):
return PasswordPolicyHandler(self)

def build_storage(self) -> Storage:
return Storage(self, self.datastores)

Loading

0 comments on commit e8e2ddb

Please sign in to comment.