Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Kerberos support for LDAP connector #564

Merged
merged 9 commits into from
Apr 28, 2020
3 changes: 2 additions & 1 deletion examples/config files - basic/connector-ldap.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ host: "ldap://ldap.example.com"
base_dn: "DC=example,DC=com"

# (optional) You can specify what Authentication method to bind LDAP
# connection with. You can choose either Anonymous, Simple or NTLM.
# connection with. You can choose either Anonymous, Simple, NTLM, or Kerberos.
# If username is not specified above, the LDAP connector will override
# the authentication method and set it to 'anonymous'.
# If you choose anonymous, you don't have to specify username and password above.
# If you choose simple, you must provide a username and password.
# If you choose NTLM, you have to specify the username in this format [Domain]\[Username]
# for example EXAMPLE\JDOE. You can specify NTLM Password Hash or ClearText for a password.
# If you choose Kerberos, you do not need to specify username and password.
# Default authentication method: Simple
# authentication_method: Simple

Expand Down
2 changes: 2 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@
':sys_platform=="linux" or sys_platform=="linux2"': [
'secretstorage',
'dbus-python',
'kerberos'
],
':sys_platform=="win32"': [
'pywin32-ctypes',
'winkerberos',
'pywin32'
],
'test': test_deps,
Expand Down
34 changes: 26 additions & 8 deletions user_sync/connector/directory_ldap.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import user_sync.identity_type
from user_sync.error import AssertionException

import platform
import ssl

def connector_metadata():
metadata = {
Expand Down Expand Up @@ -79,16 +81,23 @@ def __init__(self, caller_options):
self.user_country_code_formatter = LDAPValueFormatter(options['user_country_code_format'])

auth_method = options['authentication_method'].lower()
auth_cred_required = ['simple', 'ntlm']

if options['username'] is not None:
password = caller_config.get_credential('password', options['username'])
if auth_method in auth_cred_required:
password = caller_config.get_credential('password', options['username'])
else:
# Ignore specified credential if authentication method is either 'Kerberos' or 'Anonymous'
raise AssertionException("'username' and 'password' are not allowed when 'authentication_method' is '%s" % auth_method)
else:
# override authentication method to anonymous if username is not specified
if auth_method != 'anonymous':
if auth_method != 'anonymous' and auth_method != 'kerberos':
auth_method = 'anonymous'
logger.info("Username not specified, overriding authentication method to 'anonymous'")
# this check must come after we get the password value
caller_config.report_unused_values(logger)
if auth_method != 'kerberos':
from ldap3 import Connection

if auth_method == 'anonymous':
auth = {'authentication': ldap3.ANONYMOUS}
Expand All @@ -103,19 +112,28 @@ def __init__(self, caller_options):
'password': six.text_type(password)}
logger.debug('Connecting to: %s - Authentication Method: NTLM using username: %s', options['host'],
options['username'])
elif auth_method == 'kerberos':
if(platform.system() == 'Windows'):
from .ldap3_extended.Connection import Connection
auth = {'authentication': ldap3.SASL, 'sasl_mechanism': ldap3.GSSAPI}
logger.debug('Connecting to: %s - Authentication Method: Kerberos', options['host'])
else:
raise AssertionException('Kerberos Authentication Method is not supported on this OS. Windows Only')
adorton-adobe marked this conversation as resolved.
Show resolved Hide resolved
else:
raise AssertionException('LDAP Authentication Method is not supported: %s' % auth_method)

# TODO TLS****
# if not options['require_tls_cert']:
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
tls = None
auto_bind = ldap3.AUTO_BIND_NO_TLS
if options['require_tls_cert']:
tls = ldap3.Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLSv1_2)
auto_bind = ldap3.AUTO_BIND_TLS_BEFORE_BIND
try:
server = ldap3.Server(host=options['host'], allowed_referral_hosts=True)
connection = ldap3.Connection(server, auto_bind=True, read_only=True, **auth)
server = ldap3.Server(host=options['host'], allowed_referral_hosts=True, tls=tls)
connection = Connection(server, auto_bind=auto_bind, read_only=True, **auth)
except Exception as e:
raise AssertionException('LDAP connection failure: %s' % e)
self.connection = connection
logger.debug('Connected')
logger.debug('Connected as %s', connection.extend.standard.who_am_i())
self.user_by_dn = {}
self.additional_group_filters = None

Expand Down
205 changes: 205 additions & 0 deletions user_sync/connector/ldap3_extended/Connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Copyright (c) 2020 Adobe Inc. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import ldap3
bhunut-adobe marked this conversation as resolved.
Show resolved Hide resolved
from ldap3.core.exceptions import LDAPCommunicationError
from ldap3.protocol.sasl.sasl import send_sasl_negotiation
from ldap3.protocol.sasl.sasl import abort_sasl_negotiation

from ldap3.protocol.sasl.external import sasl_external
from ldap3.protocol.sasl.digestMd5 import sasl_digest_md5
from ldap3.protocol.sasl.plain import sasl_plain
from ldap3.utils.log import log, log_enabled, BASIC
from ldap3 import EXTERNAL, DIGEST_MD5, GSSAPI

from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes

import base64
import socket

try:
import winkerberos as kerberos
except ImportError:
import kerberos


NO_SECURITY_LAYER = 1
INTEGRITY_PROTECTION = 2
CONFIDENTIALITY_PROTECTION = 4


class Connection(ldap3.Connection):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def do_sasl_bind(self,
controls):
if log_enabled(BASIC):
log(BASIC, 'start SASL BIND operation via <%s>', self)
self.last_error = None
with self.connection_lock:
result = None

if not self.sasl_in_progress:
self.sasl_in_progress = True
try:
if self.sasl_mechanism == EXTERNAL:
result = sasl_external(self, controls)
elif self.sasl_mechanism == DIGEST_MD5:
result = sasl_digest_md5(self, controls)
elif self.sasl_mechanism == GSSAPI:
result = sasl_gssapi(self, controls)
elif self.sasl_mechanism == 'PLAIN':
result = sasl_plain(self, controls)
finally:
self.sasl_in_progress = False

if log_enabled(BASIC):
log(BASIC, 'done SASL BIND operation, result <%s>', result)

return result

def get_channel_bindings(ssl_socket):
try:
server_certificate = ssl_socket.getpeercert(True)
except:
return None
cert = x509.load_der_x509_certificate(server_certificate, default_backend())
hash_algorithm = cert.signature_hash_algorithm
if hash_algorithm.name in ('md5', 'sha1'):
digest = hashes.Hash(hashes.SHA256(), default_backend())
else:
digest = hashes.Hash(hash_algorithm, default_backend())
digest.update(server_certificate)
application_data = b"tls-server-end-point:" + digest.finalize()
return kerberos.channelBindings(application_data=application_data)

def sasl_gssapi(connection, controls):
"""
Performs a bind using the Kerberos v5 ("GSSAPI") SASL mechanism
from RFC 4752. Does not support any security layers, only authentication!
sasl_credentials can be empty or a tuple with one or two elements.
The first element determines which service principal to request a ticket
for and can be one of the following:
- None or False, to use the hostname from the Server object
- True to perform a reverse DNS lookup to retrieve the canonical hostname
for the hosts IP address
- A string containing the hostname
The optional second element is what authorization ID to request.
- If omitted or None, the authentication ID is used as the authorization ID
- If a string, the authorization ID to use. Should start with "dn:" or
"user:".
"""
# pylint: disable=too-many-branches
target_name = None
authz_id = b''
if connection.sasl_credentials:
if (len(connection.sasl_credentials) >= 1 and
connection.sasl_credentials[0]):
if connection.sasl_credentials[0] is True:
hostname = \
socket.gethostbyaddr(connection.socket.getpeername()[0])[0]
target_name = 'ldap@' + hostname

else:
target_name = 'ldap@' + connection.sasl_credentials[0]
if (len(connection.sasl_credentials) >= 2 and
connection.sasl_credentials[1]):
authz_id = connection.sasl_credentials[1].encode("utf-8")
if target_name is None:
target_name = 'ldap@' + connection.server.host

gssflags = (
kerberos.GSS_C_MUTUAL_FLAG |
kerberos.GSS_C_SEQUENCE_FLAG |
kerberos.GSS_C_INTEG_FLAG |
kerberos.GSS_C_CONF_FLAG
)
channel_bindings = get_channel_bindings(connection.socket)
_, ctx = kerberos.authGSSClientInit(target_name, gssflags=gssflags)

in_token = b''
try:
while True:
if channel_bindings:
status = kerberos.authGSSClientStep(
ctx,
base64.b64encode(in_token).decode('ascii'),
channel_bindings=channel_bindings
)
else:
status = kerberos.authGSSClientStep(
ctx,
base64.b64encode(in_token).decode('ascii')
)
out_token = kerberos.authGSSClientResponse(ctx) or ''
result = send_sasl_negotiation(
connection,
controls,
base64.b64decode(out_token)
)
in_token = result['saslCreds'] or b''
if status == kerberos.AUTH_GSS_COMPLETE:
break

kerberos.authGSSClientUnwrap(
ctx,
base64.b64encode(in_token).decode('ascii')
)
unwrapped_token = base64.b64decode(
kerberos.authGSSClientResponse(ctx) or ''
)

if len(unwrapped_token) != 4:
raise LDAPCommunicationError('Incorrect response from server')

server_security_layers = unwrapped_token[0]
if not isinstance(server_security_layers, int):
server_security_layers = ord(server_security_layers)
if server_security_layers in (0, NO_SECURITY_LAYER):
if unwrapped_token.message[1:] != '\x00\x00\x00':
raise LDAPCommunicationError(
'Server max buffer size must be 0 if no security layer'
)
if not server_security_layers & NO_SECURITY_LAYER:
raise LDAPCommunicationError(
'Server requires a security layer, but this is not implemented'
)

client_security_layers = bytearray([NO_SECURITY_LAYER, 0, 0, 0])
kerberos.authGSSClientWrap(
ctx,
base64.b64encode(
bytes(client_security_layers) + authz_id
).decode('ascii')
)
out_token = kerberos.authGSSClientResponse(ctx) or ''

return send_sasl_negotiation(
connection,
controls,
base64.b64decode(out_token)
)
except (kerberos.GSSError, LDAPCommunicationError):
abort_sasl_negotiation(connection, controls)
raise
20 changes: 20 additions & 0 deletions user_sync/connector/ldap3_extended/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (c) 2016-2017 Adobe Inc. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.