Implement pluggable password auth
Allows delegating the password auth to an external module. This also moves the LDAP auth to using this system, allowing it to be removed from the synapse tree entirely in the future.
This commit is contained in:
parent
f7bcdbe56c
commit
850b103b36
|
@ -30,7 +30,7 @@ from .saml2 import SAML2Config
|
||||||
from .cas import CasConfig
|
from .cas import CasConfig
|
||||||
from .password import PasswordConfig
|
from .password import PasswordConfig
|
||||||
from .jwt import JWTConfig
|
from .jwt import JWTConfig
|
||||||
from .ldap import LDAPConfig
|
from .password_auth_providers import PasswordAuthProviderConfig
|
||||||
from .emailconfig import EmailConfig
|
from .emailconfig import EmailConfig
|
||||||
from .workers import WorkerConfig
|
from .workers import WorkerConfig
|
||||||
|
|
||||||
|
@ -39,8 +39,8 @@ class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
|
||||||
RatelimitConfig, ContentRepositoryConfig, CaptchaConfig,
|
RatelimitConfig, ContentRepositoryConfig, CaptchaConfig,
|
||||||
VoipConfig, RegistrationConfig, MetricsConfig, ApiConfig,
|
VoipConfig, RegistrationConfig, MetricsConfig, ApiConfig,
|
||||||
AppServiceConfig, KeyConfig, SAML2Config, CasConfig,
|
AppServiceConfig, KeyConfig, SAML2Config, CasConfig,
|
||||||
JWTConfig, LDAPConfig, PasswordConfig, EmailConfig,
|
JWTConfig, PasswordConfig, EmailConfig,
|
||||||
WorkerConfig,):
|
WorkerConfig, PasswordAuthProviderConfig,):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,100 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright 2015 Niklas Riekenbrauck
|
|
||||||
#
|
|
||||||
# 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 ._base import Config, ConfigError
|
|
||||||
|
|
||||||
|
|
||||||
MISSING_LDAP3 = (
|
|
||||||
"Missing ldap3 library. This is required for LDAP Authentication."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class LDAPMode(object):
|
|
||||||
SIMPLE = "simple",
|
|
||||||
SEARCH = "search",
|
|
||||||
|
|
||||||
LIST = (SIMPLE, SEARCH)
|
|
||||||
|
|
||||||
|
|
||||||
class LDAPConfig(Config):
|
|
||||||
def read_config(self, config):
|
|
||||||
ldap_config = config.get("ldap_config", {})
|
|
||||||
|
|
||||||
self.ldap_enabled = ldap_config.get("enabled", False)
|
|
||||||
|
|
||||||
if self.ldap_enabled:
|
|
||||||
# verify dependencies are available
|
|
||||||
try:
|
|
||||||
import ldap3
|
|
||||||
ldap3 # to stop unused lint
|
|
||||||
except ImportError:
|
|
||||||
raise ConfigError(MISSING_LDAP3)
|
|
||||||
|
|
||||||
self.ldap_mode = LDAPMode.SIMPLE
|
|
||||||
|
|
||||||
# verify config sanity
|
|
||||||
self.require_keys(ldap_config, [
|
|
||||||
"uri",
|
|
||||||
"base",
|
|
||||||
"attributes",
|
|
||||||
])
|
|
||||||
|
|
||||||
self.ldap_uri = ldap_config["uri"]
|
|
||||||
self.ldap_start_tls = ldap_config.get("start_tls", False)
|
|
||||||
self.ldap_base = ldap_config["base"]
|
|
||||||
self.ldap_attributes = ldap_config["attributes"]
|
|
||||||
|
|
||||||
if "bind_dn" in ldap_config:
|
|
||||||
self.ldap_mode = LDAPMode.SEARCH
|
|
||||||
self.require_keys(ldap_config, [
|
|
||||||
"bind_dn",
|
|
||||||
"bind_password",
|
|
||||||
])
|
|
||||||
|
|
||||||
self.ldap_bind_dn = ldap_config["bind_dn"]
|
|
||||||
self.ldap_bind_password = ldap_config["bind_password"]
|
|
||||||
self.ldap_filter = ldap_config.get("filter", None)
|
|
||||||
|
|
||||||
# verify attribute lookup
|
|
||||||
self.require_keys(ldap_config['attributes'], [
|
|
||||||
"uid",
|
|
||||||
"name",
|
|
||||||
"mail",
|
|
||||||
])
|
|
||||||
|
|
||||||
def require_keys(self, config, required):
|
|
||||||
missing = [key for key in required if key not in config]
|
|
||||||
if missing:
|
|
||||||
raise ConfigError(
|
|
||||||
"LDAP enabled but missing required config values: {}".format(
|
|
||||||
", ".join(missing)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def default_config(self, **kwargs):
|
|
||||||
return """\
|
|
||||||
# ldap_config:
|
|
||||||
# enabled: true
|
|
||||||
# uri: "ldap://ldap.example.com:389"
|
|
||||||
# start_tls: true
|
|
||||||
# base: "ou=users,dc=example,dc=com"
|
|
||||||
# attributes:
|
|
||||||
# uid: "cn"
|
|
||||||
# mail: "email"
|
|
||||||
# name: "givenName"
|
|
||||||
# #bind_dn:
|
|
||||||
# #bind_password:
|
|
||||||
# #filter: "(objectClass=posixAccount)"
|
|
||||||
"""
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2016 Openmarket
|
||||||
|
#
|
||||||
|
# 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 ._base import Config
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordAuthProviderConfig(Config):
|
||||||
|
def read_config(self, config):
|
||||||
|
self.password_providers = []
|
||||||
|
|
||||||
|
# We want to be backwards compatible with the old `ldap_config`
|
||||||
|
# param.
|
||||||
|
ldap_config = config.get("ldap_config", {})
|
||||||
|
self.ldap_enabled = ldap_config.get("enabled", False)
|
||||||
|
if self.ldap_enabled:
|
||||||
|
from synapse.util.ldap_auth_provider import LdapAuthProvider
|
||||||
|
parsed_config = LdapAuthProvider.parse_config(ldap_config)
|
||||||
|
self.password_providers.append((LdapAuthProvider, parsed_config))
|
||||||
|
|
||||||
|
providers = config.get("password_providers", [])
|
||||||
|
for provider in providers:
|
||||||
|
# We need to import the module, and then pick the class out of
|
||||||
|
# that, so we split based on the last dot.
|
||||||
|
module, clz = provider['module'].rsplit(".", 1)
|
||||||
|
module = importlib.import_module(module)
|
||||||
|
provider_class = getattr(module, clz)
|
||||||
|
|
||||||
|
provider_config = provider_class.parse_config(provider["config"])
|
||||||
|
self.password_providers.append((provider_class, provider_config))
|
||||||
|
|
||||||
|
def default_config(self, **kwargs):
|
||||||
|
return """\
|
||||||
|
# password_providers:
|
||||||
|
# - module: "synapse.util.ldap_auth_provider.LdapAuthProvider"
|
||||||
|
# config:
|
||||||
|
# enabled: true
|
||||||
|
# uri: "ldap://ldap.example.com:389"
|
||||||
|
# start_tls: true
|
||||||
|
# base: "ou=users,dc=example,dc=com"
|
||||||
|
# attributes:
|
||||||
|
# uid: "cn"
|
||||||
|
# mail: "email"
|
||||||
|
# name: "givenName"
|
||||||
|
# #bind_dn:
|
||||||
|
# #bind_password:
|
||||||
|
# #filter: "(objectClass=posixAccount)"
|
||||||
|
"""
|
|
@ -20,7 +20,6 @@ from synapse.api.constants import LoginType
|
||||||
from synapse.types import UserID
|
from synapse.types import UserID
|
||||||
from synapse.api.errors import AuthError, LoginError, Codes, StoreError, SynapseError
|
from synapse.api.errors import AuthError, LoginError, Codes, StoreError, SynapseError
|
||||||
from synapse.util.async import run_on_reactor
|
from synapse.util.async import run_on_reactor
|
||||||
from synapse.config.ldap import LDAPMode
|
|
||||||
|
|
||||||
from twisted.web.client import PartialDownloadError
|
from twisted.web.client import PartialDownloadError
|
||||||
|
|
||||||
|
@ -29,13 +28,6 @@ import bcrypt
|
||||||
import pymacaroons
|
import pymacaroons
|
||||||
import simplejson
|
import simplejson
|
||||||
|
|
||||||
try:
|
|
||||||
import ldap3
|
|
||||||
import ldap3.core.exceptions
|
|
||||||
except ImportError:
|
|
||||||
ldap3 = None
|
|
||||||
pass
|
|
||||||
|
|
||||||
import synapse.util.stringutils as stringutils
|
import synapse.util.stringutils as stringutils
|
||||||
|
|
||||||
|
|
||||||
|
@ -61,21 +53,14 @@ class AuthHandler(BaseHandler):
|
||||||
self.sessions = {}
|
self.sessions = {}
|
||||||
self.INVALID_TOKEN_HTTP_STATUS = 401
|
self.INVALID_TOKEN_HTTP_STATUS = 401
|
||||||
|
|
||||||
self.ldap_enabled = hs.config.ldap_enabled
|
account_handler = _AccountHandler(
|
||||||
if self.ldap_enabled:
|
hs, check_user_exists=self.check_user_exists
|
||||||
if not ldap3:
|
)
|
||||||
raise RuntimeError(
|
|
||||||
'Missing ldap3 library. This is required for LDAP Authentication.'
|
self.password_providers = [
|
||||||
)
|
module(config=config, account_handler=account_handler)
|
||||||
self.ldap_mode = hs.config.ldap_mode
|
for module, config in hs.config.password_providers
|
||||||
self.ldap_uri = hs.config.ldap_uri
|
]
|
||||||
self.ldap_start_tls = hs.config.ldap_start_tls
|
|
||||||
self.ldap_base = hs.config.ldap_base
|
|
||||||
self.ldap_attributes = hs.config.ldap_attributes
|
|
||||||
if self.ldap_mode == LDAPMode.SEARCH:
|
|
||||||
self.ldap_bind_dn = hs.config.ldap_bind_dn
|
|
||||||
self.ldap_bind_password = hs.config.ldap_bind_password
|
|
||||||
self.ldap_filter = hs.config.ldap_filter
|
|
||||||
|
|
||||||
self.hs = hs # FIXME better possibility to access registrationHandler later?
|
self.hs = hs # FIXME better possibility to access registrationHandler later?
|
||||||
self.device_handler = hs.get_device_handler()
|
self.device_handler = hs.get_device_handler()
|
||||||
|
@ -477,9 +462,10 @@ class AuthHandler(BaseHandler):
|
||||||
Raises:
|
Raises:
|
||||||
LoginError if the password was incorrect
|
LoginError if the password was incorrect
|
||||||
"""
|
"""
|
||||||
valid_ldap = yield self._check_ldap_password(user_id, password)
|
for provider in self.password_providers:
|
||||||
if valid_ldap:
|
is_valid = yield provider.check_password(user_id, password)
|
||||||
defer.returnValue(user_id)
|
if is_valid:
|
||||||
|
defer.returnValue(user_id)
|
||||||
|
|
||||||
result = yield self._check_local_password(user_id, password)
|
result = yield self._check_local_password(user_id, password)
|
||||||
defer.returnValue(result)
|
defer.returnValue(result)
|
||||||
|
@ -505,275 +491,6 @@ class AuthHandler(BaseHandler):
|
||||||
raise LoginError(403, "", errcode=Codes.FORBIDDEN)
|
raise LoginError(403, "", errcode=Codes.FORBIDDEN)
|
||||||
defer.returnValue(user_id)
|
defer.returnValue(user_id)
|
||||||
|
|
||||||
def _ldap_simple_bind(self, server, localpart, password):
|
|
||||||
""" Attempt a simple bind with the credentials
|
|
||||||
given by the user against the LDAP server.
|
|
||||||
|
|
||||||
Returns True, LDAP3Connection
|
|
||||||
if the bind was successful
|
|
||||||
Returns False, None
|
|
||||||
if an error occured
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# bind with the the local users ldap credentials
|
|
||||||
bind_dn = "{prop}={value},{base}".format(
|
|
||||||
prop=self.ldap_attributes['uid'],
|
|
||||||
value=localpart,
|
|
||||||
base=self.ldap_base
|
|
||||||
)
|
|
||||||
conn = ldap3.Connection(server, bind_dn, password)
|
|
||||||
logger.debug(
|
|
||||||
"Established LDAP connection in simple bind mode: %s",
|
|
||||||
conn
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.ldap_start_tls:
|
|
||||||
conn.start_tls()
|
|
||||||
logger.debug(
|
|
||||||
"Upgraded LDAP connection in simple bind mode through StartTLS: %s",
|
|
||||||
conn
|
|
||||||
)
|
|
||||||
|
|
||||||
if conn.bind():
|
|
||||||
# GOOD: bind okay
|
|
||||||
logger.debug("LDAP Bind successful in simple bind mode.")
|
|
||||||
return True, conn
|
|
||||||
|
|
||||||
# BAD: bind failed
|
|
||||||
logger.info(
|
|
||||||
"Binding against LDAP failed for '%s' failed: %s",
|
|
||||||
localpart, conn.result['description']
|
|
||||||
)
|
|
||||||
conn.unbind()
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
except ldap3.core.exceptions.LDAPException as e:
|
|
||||||
logger.warn("Error during LDAP authentication: %s", e)
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
def _ldap_authenticated_search(self, server, localpart, password):
|
|
||||||
""" Attempt to login with the preconfigured bind_dn
|
|
||||||
and then continue searching and filtering within
|
|
||||||
the base_dn
|
|
||||||
|
|
||||||
Returns (True, LDAP3Connection)
|
|
||||||
if a single matching DN within the base was found
|
|
||||||
that matched the filter expression, and with which
|
|
||||||
a successful bind was achieved
|
|
||||||
|
|
||||||
The LDAP3Connection returned is the instance that was used to
|
|
||||||
verify the password not the one using the configured bind_dn.
|
|
||||||
Returns (False, None)
|
|
||||||
if an error occured
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = ldap3.Connection(
|
|
||||||
server,
|
|
||||||
self.ldap_bind_dn,
|
|
||||||
self.ldap_bind_password
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
"Established LDAP connection in search mode: %s",
|
|
||||||
conn
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.ldap_start_tls:
|
|
||||||
conn.start_tls()
|
|
||||||
logger.debug(
|
|
||||||
"Upgraded LDAP connection in search mode through StartTLS: %s",
|
|
||||||
conn
|
|
||||||
)
|
|
||||||
|
|
||||||
if not conn.bind():
|
|
||||||
logger.warn(
|
|
||||||
"Binding against LDAP with `bind_dn` failed: %s",
|
|
||||||
conn.result['description']
|
|
||||||
)
|
|
||||||
conn.unbind()
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
# construct search_filter like (uid=localpart)
|
|
||||||
query = "({prop}={value})".format(
|
|
||||||
prop=self.ldap_attributes['uid'],
|
|
||||||
value=localpart
|
|
||||||
)
|
|
||||||
if self.ldap_filter:
|
|
||||||
# combine with the AND expression
|
|
||||||
query = "(&{query}{filter})".format(
|
|
||||||
query=query,
|
|
||||||
filter=self.ldap_filter
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
"LDAP search filter: %s",
|
|
||||||
query
|
|
||||||
)
|
|
||||||
conn.search(
|
|
||||||
search_base=self.ldap_base,
|
|
||||||
search_filter=query
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(conn.response) == 1:
|
|
||||||
# GOOD: found exactly one result
|
|
||||||
user_dn = conn.response[0]['dn']
|
|
||||||
logger.debug('LDAP search found dn: %s', user_dn)
|
|
||||||
|
|
||||||
# unbind and simple bind with user_dn to verify the password
|
|
||||||
# Note: do not use rebind(), for some reason it did not verify
|
|
||||||
# the password for me!
|
|
||||||
conn.unbind()
|
|
||||||
return self._ldap_simple_bind(server, localpart, password)
|
|
||||||
else:
|
|
||||||
# BAD: found 0 or > 1 results, abort!
|
|
||||||
if len(conn.response) == 0:
|
|
||||||
logger.info(
|
|
||||||
"LDAP search returned no results for '%s'",
|
|
||||||
localpart
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"LDAP search returned too many (%s) results for '%s'",
|
|
||||||
len(conn.response), localpart
|
|
||||||
)
|
|
||||||
conn.unbind()
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
except ldap3.core.exceptions.LDAPException as e:
|
|
||||||
logger.warn("Error during LDAP authentication: %s", e)
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
|
||||||
def _check_ldap_password(self, user_id, password):
|
|
||||||
""" Attempt to authenticate a user against an LDAP Server
|
|
||||||
and register an account if none exists.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if authentication against LDAP was successful
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not ldap3 or not self.ldap_enabled:
|
|
||||||
defer.returnValue(False)
|
|
||||||
|
|
||||||
localpart = UserID.from_string(user_id).localpart
|
|
||||||
|
|
||||||
try:
|
|
||||||
server = ldap3.Server(self.ldap_uri)
|
|
||||||
logger.debug(
|
|
||||||
"Attempting LDAP connection with %s",
|
|
||||||
self.ldap_uri
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.ldap_mode == LDAPMode.SIMPLE:
|
|
||||||
result, conn = self._ldap_simple_bind(
|
|
||||||
server=server, localpart=localpart, password=password
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
'LDAP authentication method simple bind returned: %s (conn: %s)',
|
|
||||||
result,
|
|
||||||
conn
|
|
||||||
)
|
|
||||||
if not result:
|
|
||||||
defer.returnValue(False)
|
|
||||||
elif self.ldap_mode == LDAPMode.SEARCH:
|
|
||||||
result, conn = self._ldap_authenticated_search(
|
|
||||||
server=server, localpart=localpart, password=password
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
'LDAP auth method authenticated search returned: %s (conn: %s)',
|
|
||||||
result,
|
|
||||||
conn
|
|
||||||
)
|
|
||||||
if not result:
|
|
||||||
defer.returnValue(False)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
|
||||||
'Invalid LDAP mode specified: {mode}'.format(
|
|
||||||
mode=self.ldap_mode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(
|
|
||||||
"User authenticated against LDAP server: %s",
|
|
||||||
conn
|
|
||||||
)
|
|
||||||
except NameError:
|
|
||||||
logger.warn("Authentication method yielded no LDAP connection, aborting!")
|
|
||||||
defer.returnValue(False)
|
|
||||||
|
|
||||||
# check if user with user_id exists
|
|
||||||
if (yield self.check_user_exists(user_id)):
|
|
||||||
# exists, authentication complete
|
|
||||||
conn.unbind()
|
|
||||||
defer.returnValue(True)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# does not exist, fetch metadata for account creation from
|
|
||||||
# existing ldap connection
|
|
||||||
query = "({prop}={value})".format(
|
|
||||||
prop=self.ldap_attributes['uid'],
|
|
||||||
value=localpart
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.ldap_mode == LDAPMode.SEARCH and self.ldap_filter:
|
|
||||||
query = "(&{filter}{user_filter})".format(
|
|
||||||
filter=query,
|
|
||||||
user_filter=self.ldap_filter
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
"ldap registration filter: %s",
|
|
||||||
query
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.search(
|
|
||||||
search_base=self.ldap_base,
|
|
||||||
search_filter=query,
|
|
||||||
attributes=[
|
|
||||||
self.ldap_attributes['name'],
|
|
||||||
self.ldap_attributes['mail']
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(conn.response) == 1:
|
|
||||||
attrs = conn.response[0]['attributes']
|
|
||||||
mail = attrs[self.ldap_attributes['mail']][0]
|
|
||||||
name = attrs[self.ldap_attributes['name']][0]
|
|
||||||
|
|
||||||
# create account
|
|
||||||
registration_handler = self.hs.get_handlers().registration_handler
|
|
||||||
user_id, access_token = (
|
|
||||||
yield registration_handler.register(localpart=localpart)
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: bind email, set displayname with data from ldap directory
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Registration based on LDAP data was successful: %d: %s (%s, %)",
|
|
||||||
user_id,
|
|
||||||
localpart,
|
|
||||||
name,
|
|
||||||
mail
|
|
||||||
)
|
|
||||||
|
|
||||||
defer.returnValue(True)
|
|
||||||
else:
|
|
||||||
if len(conn.response) == 0:
|
|
||||||
logger.warn("LDAP registration failed, no result.")
|
|
||||||
else:
|
|
||||||
logger.warn(
|
|
||||||
"LDAP registration failed, too many results (%s)",
|
|
||||||
len(conn.response)
|
|
||||||
)
|
|
||||||
|
|
||||||
defer.returnValue(False)
|
|
||||||
|
|
||||||
defer.returnValue(False)
|
|
||||||
|
|
||||||
except ldap3.core.exceptions.LDAPException as e:
|
|
||||||
logger.warn("Error during ldap authentication: %s", e)
|
|
||||||
defer.returnValue(False)
|
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def issue_access_token(self, user_id, device_id=None):
|
def issue_access_token(self, user_id, device_id=None):
|
||||||
access_token = self.generate_access_token(user_id)
|
access_token = self.generate_access_token(user_id)
|
||||||
|
@ -911,3 +628,30 @@ class AuthHandler(BaseHandler):
|
||||||
stored_hash.encode('utf-8')) == stored_hash
|
stored_hash.encode('utf-8')) == stored_hash
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _AccountHandler(object):
|
||||||
|
"""A proxy object that gets passed to password auth providers so they
|
||||||
|
can register new users etc if necessary.
|
||||||
|
"""
|
||||||
|
def __init__(self, hs, check_user_exists):
|
||||||
|
self.hs = hs
|
||||||
|
|
||||||
|
self._check_user_exists = check_user_exists
|
||||||
|
|
||||||
|
def check_user_exists(self, user_id):
|
||||||
|
"""Check if user exissts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deferred(bool)
|
||||||
|
"""
|
||||||
|
return self._check_user_exists(user_id)
|
||||||
|
|
||||||
|
def register(self, localpart):
|
||||||
|
"""Registers a new user with given localpart
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Deferred: a 2-tuple of (user_id, access_token)
|
||||||
|
"""
|
||||||
|
reg = self.hs.get_handlers().registration_handler
|
||||||
|
return reg.register(localpart=localpart)
|
||||||
|
|
|
@ -0,0 +1,368 @@
|
||||||
|
|
||||||
|
from twisted.internet import defer
|
||||||
|
|
||||||
|
from synapse.config._base import ConfigError
|
||||||
|
from synapse.types import UserID
|
||||||
|
|
||||||
|
import ldap3
|
||||||
|
import ldap3.core.exceptions
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ldap3
|
||||||
|
import ldap3.core.exceptions
|
||||||
|
except ImportError:
|
||||||
|
ldap3 = None
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPMode(object):
|
||||||
|
SIMPLE = "simple",
|
||||||
|
SEARCH = "search",
|
||||||
|
|
||||||
|
LIST = (SIMPLE, SEARCH)
|
||||||
|
|
||||||
|
|
||||||
|
class LdapAuthProvider(object):
|
||||||
|
__version__ = "0.1"
|
||||||
|
|
||||||
|
def __init__(self, config, account_handler):
|
||||||
|
self.account_handler = account_handler
|
||||||
|
|
||||||
|
if not ldap3:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Missing ldap3 library. This is required for LDAP Authentication.'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.ldap_mode = config.mode
|
||||||
|
self.ldap_uri = config.uri
|
||||||
|
self.ldap_start_tls = config.start_tls
|
||||||
|
self.ldap_base = config.base
|
||||||
|
self.ldap_attributes = config.attributes
|
||||||
|
if self.ldap_mode == LDAPMode.SEARCH:
|
||||||
|
self.ldap_bind_dn = config.bind_dn
|
||||||
|
self.ldap_bind_password = config.bind_password
|
||||||
|
self.ldap_filter = config.filter
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def check_password(self, user_id, password):
|
||||||
|
""" Attempt to authenticate a user against an LDAP Server
|
||||||
|
and register an account if none exists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if authentication against LDAP was successful
|
||||||
|
"""
|
||||||
|
localpart = UserID.from_string(user_id).localpart
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = ldap3.Server(self.ldap_uri)
|
||||||
|
logger.debug(
|
||||||
|
"Attempting LDAP connection with %s",
|
||||||
|
self.ldap_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.ldap_mode == LDAPMode.SIMPLE:
|
||||||
|
result, conn = self._ldap_simple_bind(
|
||||||
|
server=server, localpart=localpart, password=password
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
'LDAP authentication method simple bind returned: %s (conn: %s)',
|
||||||
|
result,
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
defer.returnValue(False)
|
||||||
|
elif self.ldap_mode == LDAPMode.SEARCH:
|
||||||
|
result, conn = self._ldap_authenticated_search(
|
||||||
|
server=server, localpart=localpart, password=password
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
'LDAP auth method authenticated search returned: %s (conn: %s)',
|
||||||
|
result,
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
defer.returnValue(False)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
'Invalid LDAP mode specified: {mode}'.format(
|
||||||
|
mode=self.ldap_mode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"User authenticated against LDAP server: %s",
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
except NameError:
|
||||||
|
logger.warn(
|
||||||
|
"Authentication method yielded no LDAP connection, aborting!"
|
||||||
|
)
|
||||||
|
defer.returnValue(False)
|
||||||
|
|
||||||
|
# check if user with user_id exists
|
||||||
|
if (yield self.account_handler.check_user_exists(user_id)):
|
||||||
|
# exists, authentication complete
|
||||||
|
conn.unbind()
|
||||||
|
defer.returnValue(True)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# does not exist, fetch metadata for account creation from
|
||||||
|
# existing ldap connection
|
||||||
|
query = "({prop}={value})".format(
|
||||||
|
prop=self.ldap_attributes['uid'],
|
||||||
|
value=localpart
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.ldap_mode == LDAPMode.SEARCH and self.ldap_filter:
|
||||||
|
query = "(&{filter}{user_filter})".format(
|
||||||
|
filter=query,
|
||||||
|
user_filter=self.ldap_filter
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"ldap registration filter: %s",
|
||||||
|
query
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
search_base=self.ldap_base,
|
||||||
|
search_filter=query,
|
||||||
|
attributes=[
|
||||||
|
self.ldap_attributes['name'],
|
||||||
|
self.ldap_attributes['mail']
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(conn.response) == 1:
|
||||||
|
attrs = conn.response[0]['attributes']
|
||||||
|
mail = attrs[self.ldap_attributes['mail']][0]
|
||||||
|
name = attrs[self.ldap_attributes['name']][0]
|
||||||
|
|
||||||
|
# create account
|
||||||
|
user_id, access_token = (
|
||||||
|
yield self.account_handler.register(localpart=localpart)
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: bind email, set displayname with data from ldap directory
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Registration based on LDAP data was successful: %d: %s (%s, %)",
|
||||||
|
user_id,
|
||||||
|
localpart,
|
||||||
|
name,
|
||||||
|
mail
|
||||||
|
)
|
||||||
|
|
||||||
|
defer.returnValue(True)
|
||||||
|
else:
|
||||||
|
if len(conn.response) == 0:
|
||||||
|
logger.warn("LDAP registration failed, no result.")
|
||||||
|
else:
|
||||||
|
logger.warn(
|
||||||
|
"LDAP registration failed, too many results (%s)",
|
||||||
|
len(conn.response)
|
||||||
|
)
|
||||||
|
|
||||||
|
defer.returnValue(False)
|
||||||
|
|
||||||
|
defer.returnValue(False)
|
||||||
|
|
||||||
|
except ldap3.core.exceptions.LDAPException as e:
|
||||||
|
logger.warn("Error during ldap authentication: %s", e)
|
||||||
|
defer.returnValue(False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_config(config):
|
||||||
|
class _LdapConfig(object):
|
||||||
|
pass
|
||||||
|
|
||||||
|
ldap_config = _LdapConfig()
|
||||||
|
|
||||||
|
ldap_config.enabled = config.get("enabled", False)
|
||||||
|
|
||||||
|
ldap_config.mode = LDAPMode.SIMPLE
|
||||||
|
|
||||||
|
# verify config sanity
|
||||||
|
_require_keys(config, [
|
||||||
|
"uri",
|
||||||
|
"base",
|
||||||
|
"attributes",
|
||||||
|
])
|
||||||
|
|
||||||
|
ldap_config.uri = config["uri"]
|
||||||
|
ldap_config.start_tls = config.get("start_tls", False)
|
||||||
|
ldap_config.base = config["base"]
|
||||||
|
ldap_config.attributes = config["attributes"]
|
||||||
|
|
||||||
|
if "bind_dn" in config:
|
||||||
|
ldap_config.mode = LDAPMode.SEARCH
|
||||||
|
_require_keys(config, [
|
||||||
|
"bind_dn",
|
||||||
|
"bind_password",
|
||||||
|
])
|
||||||
|
|
||||||
|
ldap_config.bind_dn = config["bind_dn"]
|
||||||
|
ldap_config.bind_password = config["bind_password"]
|
||||||
|
ldap_config.filter = config.get("filter", None)
|
||||||
|
|
||||||
|
# verify attribute lookup
|
||||||
|
_require_keys(config['attributes'], [
|
||||||
|
"uid",
|
||||||
|
"name",
|
||||||
|
"mail",
|
||||||
|
])
|
||||||
|
|
||||||
|
return ldap_config
|
||||||
|
|
||||||
|
def _ldap_simple_bind(self, server, localpart, password):
|
||||||
|
""" Attempt a simple bind with the credentials
|
||||||
|
given by the user against the LDAP server.
|
||||||
|
|
||||||
|
Returns True, LDAP3Connection
|
||||||
|
if the bind was successful
|
||||||
|
Returns False, None
|
||||||
|
if an error occured
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# bind with the the local users ldap credentials
|
||||||
|
bind_dn = "{prop}={value},{base}".format(
|
||||||
|
prop=self.ldap_attributes['uid'],
|
||||||
|
value=localpart,
|
||||||
|
base=self.ldap_base
|
||||||
|
)
|
||||||
|
conn = ldap3.Connection(server, bind_dn, password)
|
||||||
|
logger.debug(
|
||||||
|
"Established LDAP connection in simple bind mode: %s",
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.ldap_start_tls:
|
||||||
|
conn.start_tls()
|
||||||
|
logger.debug(
|
||||||
|
"Upgraded LDAP connection in simple bind mode through StartTLS: %s",
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
|
||||||
|
if conn.bind():
|
||||||
|
# GOOD: bind okay
|
||||||
|
logger.debug("LDAP Bind successful in simple bind mode.")
|
||||||
|
return True, conn
|
||||||
|
|
||||||
|
# BAD: bind failed
|
||||||
|
logger.info(
|
||||||
|
"Binding against LDAP failed for '%s' failed: %s",
|
||||||
|
localpart, conn.result['description']
|
||||||
|
)
|
||||||
|
conn.unbind()
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
except ldap3.core.exceptions.LDAPException as e:
|
||||||
|
logger.warn("Error during LDAP authentication: %s", e)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def _ldap_authenticated_search(self, server, localpart, password):
|
||||||
|
""" Attempt to login with the preconfigured bind_dn
|
||||||
|
and then continue searching and filtering within
|
||||||
|
the base_dn
|
||||||
|
|
||||||
|
Returns (True, LDAP3Connection)
|
||||||
|
if a single matching DN within the base was found
|
||||||
|
that matched the filter expression, and with which
|
||||||
|
a successful bind was achieved
|
||||||
|
|
||||||
|
The LDAP3Connection returned is the instance that was used to
|
||||||
|
verify the password not the one using the configured bind_dn.
|
||||||
|
Returns (False, None)
|
||||||
|
if an error occured
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = ldap3.Connection(
|
||||||
|
server,
|
||||||
|
self.ldap_bind_dn,
|
||||||
|
self.ldap_bind_password
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Established LDAP connection in search mode: %s",
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.ldap_start_tls:
|
||||||
|
conn.start_tls()
|
||||||
|
logger.debug(
|
||||||
|
"Upgraded LDAP connection in search mode through StartTLS: %s",
|
||||||
|
conn
|
||||||
|
)
|
||||||
|
|
||||||
|
if not conn.bind():
|
||||||
|
logger.warn(
|
||||||
|
"Binding against LDAP with `bind_dn` failed: %s",
|
||||||
|
conn.result['description']
|
||||||
|
)
|
||||||
|
conn.unbind()
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
# construct search_filter like (uid=localpart)
|
||||||
|
query = "({prop}={value})".format(
|
||||||
|
prop=self.ldap_attributes['uid'],
|
||||||
|
value=localpart
|
||||||
|
)
|
||||||
|
if self.ldap_filter:
|
||||||
|
# combine with the AND expression
|
||||||
|
query = "(&{query}{filter})".format(
|
||||||
|
query=query,
|
||||||
|
filter=self.ldap_filter
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"LDAP search filter: %s",
|
||||||
|
query
|
||||||
|
)
|
||||||
|
conn.search(
|
||||||
|
search_base=self.ldap_base,
|
||||||
|
search_filter=query
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(conn.response) == 1:
|
||||||
|
# GOOD: found exactly one result
|
||||||
|
user_dn = conn.response[0]['dn']
|
||||||
|
logger.debug('LDAP search found dn: %s', user_dn)
|
||||||
|
|
||||||
|
# unbind and simple bind with user_dn to verify the password
|
||||||
|
# Note: do not use rebind(), for some reason it did not verify
|
||||||
|
# the password for me!
|
||||||
|
conn.unbind()
|
||||||
|
return self._ldap_simple_bind(server, localpart, password)
|
||||||
|
else:
|
||||||
|
# BAD: found 0 or > 1 results, abort!
|
||||||
|
if len(conn.response) == 0:
|
||||||
|
logger.info(
|
||||||
|
"LDAP search returned no results for '%s'",
|
||||||
|
localpart
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"LDAP search returned too many (%s) results for '%s'",
|
||||||
|
len(conn.response), localpart
|
||||||
|
)
|
||||||
|
conn.unbind()
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
except ldap3.core.exceptions.LDAPException as e:
|
||||||
|
logger.warn("Error during LDAP authentication: %s", e)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_keys(config, required):
|
||||||
|
missing = [key for key in required if key not in config]
|
||||||
|
if missing:
|
||||||
|
raise ConfigError(
|
||||||
|
"LDAP enabled but missing required config values: {}".format(
|
||||||
|
", ".join(missing)
|
||||||
|
)
|
||||||
|
)
|
|
@ -37,6 +37,7 @@ class ApplicationServiceStoreTestCase(unittest.TestCase):
|
||||||
config = Mock(
|
config = Mock(
|
||||||
app_service_config_files=self.as_yaml_files,
|
app_service_config_files=self.as_yaml_files,
|
||||||
event_cache_size=1,
|
event_cache_size=1,
|
||||||
|
password_providers=[],
|
||||||
)
|
)
|
||||||
hs = yield setup_test_homeserver(config=config)
|
hs = yield setup_test_homeserver(config=config)
|
||||||
|
|
||||||
|
@ -112,6 +113,7 @@ class ApplicationServiceTransactionStoreTestCase(unittest.TestCase):
|
||||||
config = Mock(
|
config = Mock(
|
||||||
app_service_config_files=self.as_yaml_files,
|
app_service_config_files=self.as_yaml_files,
|
||||||
event_cache_size=1,
|
event_cache_size=1,
|
||||||
|
password_providers=[],
|
||||||
)
|
)
|
||||||
hs = yield setup_test_homeserver(config=config)
|
hs = yield setup_test_homeserver(config=config)
|
||||||
self.db_pool = hs.get_db_pool()
|
self.db_pool = hs.get_db_pool()
|
||||||
|
@ -440,7 +442,10 @@ class ApplicationServiceStoreConfigTestCase(unittest.TestCase):
|
||||||
f1 = self._write_config(suffix="1")
|
f1 = self._write_config(suffix="1")
|
||||||
f2 = self._write_config(suffix="2")
|
f2 = self._write_config(suffix="2")
|
||||||
|
|
||||||
config = Mock(app_service_config_files=[f1, f2], event_cache_size=1)
|
config = Mock(
|
||||||
|
app_service_config_files=[f1, f2], event_cache_size=1,
|
||||||
|
password_providers=[]
|
||||||
|
)
|
||||||
hs = yield setup_test_homeserver(config=config, datastore=Mock())
|
hs = yield setup_test_homeserver(config=config, datastore=Mock())
|
||||||
|
|
||||||
ApplicationServiceStore(hs)
|
ApplicationServiceStore(hs)
|
||||||
|
@ -450,7 +455,10 @@ class ApplicationServiceStoreConfigTestCase(unittest.TestCase):
|
||||||
f1 = self._write_config(id="id", suffix="1")
|
f1 = self._write_config(id="id", suffix="1")
|
||||||
f2 = self._write_config(id="id", suffix="2")
|
f2 = self._write_config(id="id", suffix="2")
|
||||||
|
|
||||||
config = Mock(app_service_config_files=[f1, f2], event_cache_size=1)
|
config = Mock(
|
||||||
|
app_service_config_files=[f1, f2], event_cache_size=1,
|
||||||
|
password_providers=[]
|
||||||
|
)
|
||||||
hs = yield setup_test_homeserver(config=config, datastore=Mock())
|
hs = yield setup_test_homeserver(config=config, datastore=Mock())
|
||||||
|
|
||||||
with self.assertRaises(ConfigError) as cm:
|
with self.assertRaises(ConfigError) as cm:
|
||||||
|
@ -466,7 +474,10 @@ class ApplicationServiceStoreConfigTestCase(unittest.TestCase):
|
||||||
f1 = self._write_config(as_token="as_token", suffix="1")
|
f1 = self._write_config(as_token="as_token", suffix="1")
|
||||||
f2 = self._write_config(as_token="as_token", suffix="2")
|
f2 = self._write_config(as_token="as_token", suffix="2")
|
||||||
|
|
||||||
config = Mock(app_service_config_files=[f1, f2], event_cache_size=1)
|
config = Mock(
|
||||||
|
app_service_config_files=[f1, f2], event_cache_size=1,
|
||||||
|
password_providers=[]
|
||||||
|
)
|
||||||
hs = yield setup_test_homeserver(config=config, datastore=Mock())
|
hs = yield setup_test_homeserver(config=config, datastore=Mock())
|
||||||
|
|
||||||
with self.assertRaises(ConfigError) as cm:
|
with self.assertRaises(ConfigError) as cm:
|
||||||
|
|
|
@ -52,6 +52,7 @@ def setup_test_homeserver(name="test", datastore=None, config=None, **kargs):
|
||||||
config.server_name = name
|
config.server_name = name
|
||||||
config.trusted_third_party_id_servers = []
|
config.trusted_third_party_id_servers = []
|
||||||
config.room_invite_state_types = []
|
config.room_invite_state_types = []
|
||||||
|
config.password_providers = []
|
||||||
|
|
||||||
config.use_frozen_dicts = True
|
config.use_frozen_dicts = True
|
||||||
config.database_config = {"name": "sqlite3"}
|
config.database_config = {"name": "sqlite3"}
|
||||||
|
|
Loading…
Reference in New Issue