Allow SAML username provider plugins (#6411)

This commit is contained in:
Andrew Morgan 2019-12-10 17:30:16 +00:00 committed by GitHub
parent b2dcddc413
commit 4947de5a14
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 414 additions and 103 deletions

1
changelog.d/6411.feature Normal file
View file

@ -0,0 +1 @@
Allow custom SAML username mapping functinality through an external provider plugin.

View file

@ -0,0 +1,77 @@
# SAML Mapping Providers
A SAML mapping provider is a Python class (loaded via a Python module) that
works out how to map attributes of a SAML response object to Matrix-specific
user attributes. Details such as user ID localpart, displayname, and even avatar
URLs are all things that can be mapped from talking to a SSO service.
As an example, a SSO service may return the email address
"john.smith@example.com" for a user, whereas Synapse will need to figure out how
to turn that into a displayname when creating a Matrix user for this individual.
It may choose `John Smith`, or `Smith, John [Example.com]` or any number of
variations. As each Synapse configuration may want something different, this is
where SAML mapping providers come into play.
## Enabling Providers
External mapping providers are provided to Synapse in the form of an external
Python module. Retrieve this module from [PyPi](https://pypi.org) or elsewhere,
then tell Synapse where to look for the handler class by editing the
`saml2_config.user_mapping_provider.module` config option.
`saml2_config.user_mapping_provider.config` allows you to provide custom
configuration options to the module. Check with the module's documentation for
what options it provides (if any). The options listed by default are for the
user mapping provider built in to Synapse. If using a custom module, you should
comment these options out and use those specified by the module instead.
## Building a Custom Mapping Provider
A custom mapping provider must specify the following methods:
* `__init__(self, parsed_config)`
- Arguments:
- `parsed_config` - A configuration object that is the return value of the
`parse_config` method. You should set any configuration options needed by
the module here.
* `saml_response_to_user_attributes(self, saml_response, failures)`
- Arguments:
- `saml_response` - A `saml2.response.AuthnResponse` object to extract user
information from.
- `failures` - An `int` that represents the amount of times the returned
mxid localpart mapping has failed. This should be used
to create a deduplicated mxid localpart which should be
returned instead. For example, if this method returns
`john.doe` as the value of `mxid_localpart` in the returned
dict, and that is already taken on the homeserver, this
method will be called again with the same parameters but
with failures=1. The method should then return a different
`mxid_localpart` value, such as `john.doe1`.
- This method must return a dictionary, which will then be used by Synapse
to build a new user. The following keys are allowed:
* `mxid_localpart` - Required. The mxid localpart of the new user.
* `displayname` - The displayname of the new user. If not provided, will default to
the value of `mxid_localpart`.
* `parse_config(config)`
- This method should have the `@staticmethod` decoration.
- Arguments:
- `config` - A `dict` representing the parsed content of the
`saml2_config.user_mapping_provider.config` homeserver config option.
Runs on homeserver startup. Providers should extract any option values
they need here.
- Whatever is returned will be passed back to the user mapping provider module's
`__init__` method during construction.
* `get_saml_attributes(config)`
- This method should have the `@staticmethod` decoration.
- Arguments:
- `config` - A object resulting from a call to `parse_config`.
- Returns a tuple of two sets. The first set equates to the saml auth
response attributes that are required for the module to function, whereas
the second set consists of those attributes which can be used if available,
but are not necessary.
## Synapse's Default Provider
Synapse has a built-in SAML mapping provider if a custom provider isn't
specified in the config. It is located at
[`synapse.handlers.saml_handler.DefaultSamlMappingProvider`](../synapse/handlers/saml_handler.py).

View file

@ -1250,33 +1250,58 @@ saml2_config:
# #
#config_path: "CONFDIR/sp_conf.py" #config_path: "CONFDIR/sp_conf.py"
# the lifetime of a SAML session. This defines how long a user has to # The lifetime of a SAML session. This defines how long a user has to
# complete the authentication process, if allow_unsolicited is unset. # complete the authentication process, if allow_unsolicited is unset.
# The default is 5 minutes. # The default is 5 minutes.
# #
#saml_session_lifetime: 5m #saml_session_lifetime: 5m
# The SAML attribute (after mapping via the attribute maps) to use to derive # An external module can be provided here as a custom solution to
# the Matrix ID from. 'uid' by default. # mapping attributes returned from a saml provider onto a matrix user.
#
user_mapping_provider:
# The custom module's class. Uncomment to use a custom module.
#
#module: mapping_provider.SamlMappingProvider
# Custom configuration values for the module. Below options are
# intended for the built-in provider, they should be changed if
# using a custom module. This section will be passed as a Python
# dictionary to the module's `parse_config` method.
#
config:
# The SAML attribute (after mapping via the attribute maps) to use
# to derive the Matrix ID from. 'uid' by default.
#
# Note: This used to be configured by the
# saml2_config.mxid_source_attribute option. If that is still
# defined, its value will be used instead.
# #
#mxid_source_attribute: displayName #mxid_source_attribute: displayName
# The mapping system to use for mapping the saml attribute onto a matrix ID. # The mapping system to use for mapping the saml attribute onto a
# matrix ID.
#
# Options include: # Options include:
# * 'hexencode' (which maps unpermitted characters to '=xx') # * 'hexencode' (which maps unpermitted characters to '=xx')
# * 'dotreplace' (which replaces unpermitted characters with '.'). # * 'dotreplace' (which replaces unpermitted characters with
# '.').
# The default is 'hexencode'. # The default is 'hexencode'.
# #
# Note: This used to be configured by the
# saml2_config.mxid_mapping option. If that is still defined, its
# value will be used instead.
#
#mxid_mapping: dotreplace #mxid_mapping: dotreplace
# In previous versions of synapse, the mapping from SAML attribute to MXID was # In previous versions of synapse, the mapping from SAML attribute to
# always calculated dynamically rather than stored in a table. For backwards- # MXID was always calculated dynamically rather than stored in a
# compatibility, we will look for user_ids matching such a pattern before # table. For backwards- compatibility, we will look for user_ids
# creating a new account. # matching such a pattern before creating a new account.
# #
# This setting controls the SAML attribute which will be used for this # This setting controls the SAML attribute which will be used for this
# backwards-compatibility lookup. Typically it should be 'uid', but if the # backwards-compatibility lookup. Typically it should be 'uid', but if
# attribute maps are changed, it may be necessary to change it. # the attribute maps are changed, it may be necessary to change it.
# #
# The default is 'uid'. # The default is 'uid'.
# #

View file

@ -14,17 +14,19 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import re import logging
from synapse.python_dependencies import DependencyException, check_requirements from synapse.python_dependencies import DependencyException, check_requirements
from synapse.types import ( from synapse.util.module_loader import load_module, load_python_module
map_username_to_mxid_localpart,
mxid_localpart_allowed_characters,
)
from synapse.util.module_loader import load_python_module
from ._base import Config, ConfigError from ._base import Config, ConfigError
logger = logging.getLogger(__name__)
DEFAULT_USER_MAPPING_PROVIDER = (
"synapse.handlers.saml_handler.DefaultSamlMappingProvider"
)
def _dict_merge(merge_dict, into_dict): def _dict_merge(merge_dict, into_dict):
"""Do a deep merge of two dicts """Do a deep merge of two dicts
@ -75,15 +77,69 @@ class SAML2Config(Config):
self.saml2_enabled = True self.saml2_enabled = True
self.saml2_mxid_source_attribute = saml2_config.get(
"mxid_source_attribute", "uid"
)
self.saml2_grandfathered_mxid_source_attribute = saml2_config.get( self.saml2_grandfathered_mxid_source_attribute = saml2_config.get(
"grandfathered_mxid_source_attribute", "uid" "grandfathered_mxid_source_attribute", "uid"
) )
saml2_config_dict = self._default_saml_config_dict() # user_mapping_provider may be None if the key is present but has no value
ump_dict = saml2_config.get("user_mapping_provider") or {}
# Use the default user mapping provider if not set
ump_dict.setdefault("module", DEFAULT_USER_MAPPING_PROVIDER)
# Ensure a config is present
ump_dict["config"] = ump_dict.get("config") or {}
if ump_dict["module"] == DEFAULT_USER_MAPPING_PROVIDER:
# Load deprecated options for use by the default module
old_mxid_source_attribute = saml2_config.get("mxid_source_attribute")
if old_mxid_source_attribute:
logger.warning(
"The config option saml2_config.mxid_source_attribute is deprecated. "
"Please use saml2_config.user_mapping_provider.config"
".mxid_source_attribute instead."
)
ump_dict["config"]["mxid_source_attribute"] = old_mxid_source_attribute
old_mxid_mapping = saml2_config.get("mxid_mapping")
if old_mxid_mapping:
logger.warning(
"The config option saml2_config.mxid_mapping is deprecated. Please "
"use saml2_config.user_mapping_provider.config.mxid_mapping instead."
)
ump_dict["config"]["mxid_mapping"] = old_mxid_mapping
# Retrieve an instance of the module's class
# Pass the config dictionary to the module for processing
(
self.saml2_user_mapping_provider_class,
self.saml2_user_mapping_provider_config,
) = load_module(ump_dict)
# Ensure loaded user mapping module has defined all necessary methods
# Note parse_config() is already checked during the call to load_module
required_methods = [
"get_saml_attributes",
"saml_response_to_user_attributes",
]
missing_methods = [
method
for method in required_methods
if not hasattr(self.saml2_user_mapping_provider_class, method)
]
if missing_methods:
raise ConfigError(
"Class specified by saml2_config."
"user_mapping_provider.module is missing required "
"methods: %s" % (", ".join(missing_methods),)
)
# Get the desired saml auth response attributes from the module
saml2_config_dict = self._default_saml_config_dict(
*self.saml2_user_mapping_provider_class.get_saml_attributes(
self.saml2_user_mapping_provider_config
)
)
_dict_merge( _dict_merge(
merge_dict=saml2_config.get("sp_config", {}), into_dict=saml2_config_dict merge_dict=saml2_config.get("sp_config", {}), into_dict=saml2_config_dict
) )
@ -103,22 +159,27 @@ class SAML2Config(Config):
saml2_config.get("saml_session_lifetime", "5m") saml2_config.get("saml_session_lifetime", "5m")
) )
mapping = saml2_config.get("mxid_mapping", "hexencode") def _default_saml_config_dict(
try: self, required_attributes: set, optional_attributes: set
self.saml2_mxid_mapper = MXID_MAPPER_MAP[mapping] ):
except KeyError: """Generate a configuration dictionary with required and optional attributes that
raise ConfigError("%s is not a known mxid_mapping" % (mapping,)) will be needed to process new user registration
def _default_saml_config_dict(self): Args:
required_attributes: SAML auth response attributes that are
necessary to function
optional_attributes: SAML auth response attributes that can be used to add
additional information to Synapse user accounts, but are not required
Returns:
dict: A SAML configuration dictionary
"""
import saml2 import saml2
public_baseurl = self.public_baseurl public_baseurl = self.public_baseurl
if public_baseurl is None: if public_baseurl is None:
raise ConfigError("saml2_config requires a public_baseurl to be set") raise ConfigError("saml2_config requires a public_baseurl to be set")
required_attributes = {"uid", self.saml2_mxid_source_attribute}
optional_attributes = {"displayName"}
if self.saml2_grandfathered_mxid_source_attribute: if self.saml2_grandfathered_mxid_source_attribute:
optional_attributes.add(self.saml2_grandfathered_mxid_source_attribute) optional_attributes.add(self.saml2_grandfathered_mxid_source_attribute)
optional_attributes -= required_attributes optional_attributes -= required_attributes
@ -207,33 +268,58 @@ class SAML2Config(Config):
# #
#config_path: "%(config_dir_path)s/sp_conf.py" #config_path: "%(config_dir_path)s/sp_conf.py"
# the lifetime of a SAML session. This defines how long a user has to # The lifetime of a SAML session. This defines how long a user has to
# complete the authentication process, if allow_unsolicited is unset. # complete the authentication process, if allow_unsolicited is unset.
# The default is 5 minutes. # The default is 5 minutes.
# #
#saml_session_lifetime: 5m #saml_session_lifetime: 5m
# The SAML attribute (after mapping via the attribute maps) to use to derive # An external module can be provided here as a custom solution to
# the Matrix ID from. 'uid' by default. # mapping attributes returned from a saml provider onto a matrix user.
#
user_mapping_provider:
# The custom module's class. Uncomment to use a custom module.
#
#module: mapping_provider.SamlMappingProvider
# Custom configuration values for the module. Below options are
# intended for the built-in provider, they should be changed if
# using a custom module. This section will be passed as a Python
# dictionary to the module's `parse_config` method.
#
config:
# The SAML attribute (after mapping via the attribute maps) to use
# to derive the Matrix ID from. 'uid' by default.
#
# Note: This used to be configured by the
# saml2_config.mxid_source_attribute option. If that is still
# defined, its value will be used instead.
# #
#mxid_source_attribute: displayName #mxid_source_attribute: displayName
# The mapping system to use for mapping the saml attribute onto a matrix ID. # The mapping system to use for mapping the saml attribute onto a
# matrix ID.
#
# Options include: # Options include:
# * 'hexencode' (which maps unpermitted characters to '=xx') # * 'hexencode' (which maps unpermitted characters to '=xx')
# * 'dotreplace' (which replaces unpermitted characters with '.'). # * 'dotreplace' (which replaces unpermitted characters with
# '.').
# The default is 'hexencode'. # The default is 'hexencode'.
# #
# Note: This used to be configured by the
# saml2_config.mxid_mapping option. If that is still defined, its
# value will be used instead.
#
#mxid_mapping: dotreplace #mxid_mapping: dotreplace
# In previous versions of synapse, the mapping from SAML attribute to MXID was # In previous versions of synapse, the mapping from SAML attribute to
# always calculated dynamically rather than stored in a table. For backwards- # MXID was always calculated dynamically rather than stored in a
# compatibility, we will look for user_ids matching such a pattern before # table. For backwards- compatibility, we will look for user_ids
# creating a new account. # matching such a pattern before creating a new account.
# #
# This setting controls the SAML attribute which will be used for this # This setting controls the SAML attribute which will be used for this
# backwards-compatibility lookup. Typically it should be 'uid', but if the # backwards-compatibility lookup. Typically it should be 'uid', but if
# attribute maps are changed, it may be necessary to change it. # the attribute maps are changed, it may be necessary to change it.
# #
# The default is 'uid'. # The default is 'uid'.
# #
@ -241,23 +327,3 @@ class SAML2Config(Config):
""" % { """ % {
"config_dir_path": config_dir_path "config_dir_path": config_dir_path
} }
DOT_REPLACE_PATTERN = re.compile(
("[^%s]" % (re.escape("".join(mxid_localpart_allowed_characters)),))
)
def dot_replace_for_mxid(username: str) -> str:
username = username.lower()
username = DOT_REPLACE_PATTERN.sub(".", username)
# regular mxids aren't allowed to start with an underscore either
username = re.sub("^_", "", username)
return username
MXID_MAPPER_MAP = {
"hexencode": map_username_to_mxid_localpart,
"dotreplace": dot_replace_for_mxid,
}

View file

@ -13,20 +13,36 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import logging import logging
import re
from typing import Tuple
import attr import attr
import saml2 import saml2
import saml2.response
from saml2.client import Saml2Client from saml2.client import Saml2Client
from synapse.api.errors import SynapseError from synapse.api.errors import SynapseError
from synapse.config import ConfigError
from synapse.http.servlet import parse_string from synapse.http.servlet import parse_string
from synapse.rest.client.v1.login import SSOAuthHandler from synapse.rest.client.v1.login import SSOAuthHandler
from synapse.types import UserID, map_username_to_mxid_localpart from synapse.types import (
UserID,
map_username_to_mxid_localpart,
mxid_localpart_allowed_characters,
)
from synapse.util.async_helpers import Linearizer from synapse.util.async_helpers import Linearizer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@attr.s
class Saml2SessionData:
"""Data we track about SAML2 sessions"""
# time the session was created, in milliseconds
creation_time = attr.ib()
class SamlHandler: class SamlHandler:
def __init__(self, hs): def __init__(self, hs):
self._saml_client = Saml2Client(hs.config.saml2_sp_config) self._saml_client = Saml2Client(hs.config.saml2_sp_config)
@ -37,11 +53,14 @@ class SamlHandler:
self._datastore = hs.get_datastore() self._datastore = hs.get_datastore()
self._hostname = hs.hostname self._hostname = hs.hostname
self._saml2_session_lifetime = hs.config.saml2_session_lifetime self._saml2_session_lifetime = hs.config.saml2_session_lifetime
self._mxid_source_attribute = hs.config.saml2_mxid_source_attribute
self._grandfathered_mxid_source_attribute = ( self._grandfathered_mxid_source_attribute = (
hs.config.saml2_grandfathered_mxid_source_attribute hs.config.saml2_grandfathered_mxid_source_attribute
) )
self._mxid_mapper = hs.config.saml2_mxid_mapper
# plugin to do custom mapping from saml response to mxid
self._user_mapping_provider = hs.config.saml2_user_mapping_provider_class(
hs.config.saml2_user_mapping_provider_config
)
# identifier for the external_ids table # identifier for the external_ids table
self._auth_provider_id = "saml" self._auth_provider_id = "saml"
@ -118,22 +137,10 @@ class SamlHandler:
remote_user_id = saml2_auth.ava["uid"][0] remote_user_id = saml2_auth.ava["uid"][0]
except KeyError: except KeyError:
logger.warning("SAML2 response lacks a 'uid' attestation") logger.warning("SAML2 response lacks a 'uid' attestation")
raise SynapseError(400, "uid not in SAML2 response") raise SynapseError(400, "'uid' not in SAML2 response")
try:
mxid_source = saml2_auth.ava[self._mxid_source_attribute][0]
except KeyError:
logger.warning(
"SAML2 response lacks a '%s' attestation", self._mxid_source_attribute
)
raise SynapseError(
400, "%s not in SAML2 response" % (self._mxid_source_attribute,)
)
self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None) self._outstanding_requests_dict.pop(saml2_auth.in_response_to, None)
displayName = saml2_auth.ava.get("displayName", [None])[0]
with (await self._mapping_lock.queue(self._auth_provider_id)): with (await self._mapping_lock.queue(self._auth_provider_id)):
# first of all, check if we already have a mapping for this user # first of all, check if we already have a mapping for this user
logger.info( logger.info(
@ -173,22 +180,46 @@ class SamlHandler:
) )
return registered_user_id return registered_user_id
# figure out a new mxid for this user # Map saml response to user attributes using the configured mapping provider
base_mxid_localpart = self._mxid_mapper(mxid_source) for i in range(1000):
attribute_dict = self._user_mapping_provider.saml_response_to_user_attributes(
saml2_auth, i
)
suffix = 0 logger.debug(
while True: "Retrieved SAML attributes from user mapping provider: %s "
localpart = base_mxid_localpart + (str(suffix) if suffix else "") "(attempt %d)",
attribute_dict,
i,
)
localpart = attribute_dict.get("mxid_localpart")
if not localpart:
logger.error(
"SAML mapping provider plugin did not return a "
"mxid_localpart object"
)
raise SynapseError(500, "Error parsing SAML2 response")
displayname = attribute_dict.get("displayname")
# Check if this mxid already exists
if not await self._datastore.get_users_by_id_case_insensitive( if not await self._datastore.get_users_by_id_case_insensitive(
UserID(localpart, self._hostname).to_string() UserID(localpart, self._hostname).to_string()
): ):
# This mxid is free
break break
suffix += 1 else:
logger.info("Allocating mxid for new user with localpart %s", localpart) # Unable to generate a username in 1000 iterations
# Break and return error to the user
raise SynapseError(
500, "Unable to generate a Matrix ID from the SAML response"
)
registered_user_id = await self._registration_handler.register_user( registered_user_id = await self._registration_handler.register_user(
localpart=localpart, default_display_name=displayName localpart=localpart, default_display_name=displayname
) )
await self._datastore.record_user_external_id( await self._datastore.record_user_external_id(
self._auth_provider_id, remote_user_id, registered_user_id self._auth_provider_id, remote_user_id, registered_user_id
) )
@ -205,9 +236,120 @@ class SamlHandler:
del self._outstanding_requests_dict[reqid] del self._outstanding_requests_dict[reqid]
@attr.s DOT_REPLACE_PATTERN = re.compile(
class Saml2SessionData: ("[^%s]" % (re.escape("".join(mxid_localpart_allowed_characters)),))
"""Data we track about SAML2 sessions""" )
# time the session was created, in milliseconds
creation_time = attr.ib() def dot_replace_for_mxid(username: str) -> str:
username = username.lower()
username = DOT_REPLACE_PATTERN.sub(".", username)
# regular mxids aren't allowed to start with an underscore either
username = re.sub("^_", "", username)
return username
MXID_MAPPER_MAP = {
"hexencode": map_username_to_mxid_localpart,
"dotreplace": dot_replace_for_mxid,
}
@attr.s
class SamlConfig(object):
mxid_source_attribute = attr.ib()
mxid_mapper = attr.ib()
class DefaultSamlMappingProvider(object):
__version__ = "0.0.1"
def __init__(self, parsed_config: SamlConfig):
"""The default SAML user mapping provider
Args:
parsed_config: Module configuration
"""
self._mxid_source_attribute = parsed_config.mxid_source_attribute
self._mxid_mapper = parsed_config.mxid_mapper
def saml_response_to_user_attributes(
self, saml_response: saml2.response.AuthnResponse, failures: int = 0,
) -> dict:
"""Maps some text from a SAML response to attributes of a new user
Args:
saml_response: A SAML auth response object
failures: How many times a call to this function with this
saml_response has resulted in a failure
Returns:
dict: A dict containing new user attributes. Possible keys:
* mxid_localpart (str): Required. The localpart of the user's mxid
* displayname (str): The displayname of the user
"""
try:
mxid_source = saml_response.ava[self._mxid_source_attribute][0]
except KeyError:
logger.warning(
"SAML2 response lacks a '%s' attestation", self._mxid_source_attribute,
)
raise SynapseError(
400, "%s not in SAML2 response" % (self._mxid_source_attribute,)
)
# Use the configured mapper for this mxid_source
base_mxid_localpart = self._mxid_mapper(mxid_source)
# Append suffix integer if last call to this function failed to produce
# a usable mxid
localpart = base_mxid_localpart + (str(failures) if failures else "")
# Retrieve the display name from the saml response
# If displayname is None, the mxid_localpart will be used instead
displayname = saml_response.ava.get("displayName", [None])[0]
return {
"mxid_localpart": localpart,
"displayname": displayname,
}
@staticmethod
def parse_config(config: dict) -> SamlConfig:
"""Parse the dict provided by the homeserver's config
Args:
config: A dictionary containing configuration options for this provider
Returns:
SamlConfig: A custom config object for this module
"""
# Parse config options and use defaults where necessary
mxid_source_attribute = config.get("mxid_source_attribute", "uid")
mapping_type = config.get("mxid_mapping", "hexencode")
# Retrieve the associating mapping function
try:
mxid_mapper = MXID_MAPPER_MAP[mapping_type]
except KeyError:
raise ConfigError(
"saml2_config.user_mapping_provider.config: '%s' is not a valid "
"mxid_mapping value" % (mapping_type,)
)
return SamlConfig(mxid_source_attribute, mxid_mapper)
@staticmethod
def get_saml_attributes(config: SamlConfig) -> Tuple[set, set]:
"""Returns the required attributes of a SAML
Args:
config: A SamlConfig object containing configuration params for this provider
Returns:
tuple[set,set]: The first set equates to the saml auth response
attributes that are required for the module to function, whereas the
second set consists of those attributes which can be used if
available, but are not necessary
"""
return {"uid", config.mxid_source_attribute}, {"displayName"}