ldap: rewrite Connector to use Source DB Entries

This commit is contained in:
Jens Langhammer 2018-11-26 18:12:04 +01:00
parent 98e10a1ca9
commit 6c0e7b9741
6 changed files with 104 additions and 363 deletions

View file

@ -4,6 +4,7 @@ from logging import getLogger
from django.contrib.auth.backends import ModelBackend from django.contrib.auth.backends import ModelBackend
from passbook.ldap.ldap_connector import LDAPConnector from passbook.ldap.ldap_connector import LDAPConnector
from passbook.ldap.models import LDAPSource
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
@ -15,7 +16,9 @@ class LDAPBackend(ModelBackend):
"""Try to authenticate a user via ldap""" """Try to authenticate a user via ldap"""
if 'password' not in kwargs: if 'password' not in kwargs:
return None return None
if not LDAPConnector.enabled: for source in LDAPSource.objects.filter(enabled=True):
_ldap = LDAPConnector(source)
user = _ldap.auth_user(**kwargs)
if user:
return user
return None return None
_ldap = LDAPConnector()
return _ldap.auth_user(**kwargs)

View file

@ -1,6 +1,4 @@
"""Wrapper for ldap3 to easily manage user""" """Wrapper for ldap3 to easily manage user"""
import os
import sys
from logging import getLogger from logging import getLogger
from time import time from time import time
@ -8,6 +6,7 @@ import ldap3
import ldap3.core.exceptions import ldap3.core.exceptions
from passbook.core.models import User from passbook.core.models import User
from passbook.ldap.models import LDAPSource
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__) LOGGER = getLogger(__name__)
@ -17,46 +16,40 @@ LOGIN_FIELD = CONFIG.y('ldap.login_field', 'userPrincipalName')
class LDAPConnector: class LDAPConnector:
"""Wrapper for ldap3 to easily manage user""" """Wrapper for ldap3 to easily manage user authentication and creation"""
con = None _server = None
domain = None _connection = None
base_dn = None _source = None
mock = False
create_users_enabled = False
def __init__(self, mock=False, con_args=None, server_args=None): def __init__(self, source: LDAPSource):
super().__init__() self._source = source
self.create_users_enabled = CONFIG.y('ldap.create_users')
if not LDAPConnector.enabled: if not self._source.enabled:
LOGGER.debug("LDAP not Enabled") LOGGER.debug("LDAP not Enabled")
if not con_args: # if not con_args:
con_args = {} # con_args = {}
if not server_args: # if not server_args:
server_args = {} # server_args = {}
# Either use mock argument or test is in argv # Either use mock argument or test is in argv
self.domain = CONFIG.y('ldap.domain') # if mock or any('test' in arg for arg in sys.argv):
self.base_dn = CONFIG.y('ldap.base_dn') # self.mock = True
if mock or any('test' in arg for arg in sys.argv): # self.create_users_enabled = True
self.mock = True # con_args['client_strategy'] = ldap3.MOCK_SYNC
self.create_users_enabled = True # server_args['get_info'] = ldap3.OFFLINE_AD_2012_R2
con_args['client_strategy'] = ldap3.MOCK_SYNC # if self.mock:
server_args['get_info'] = ldap3.OFFLINE_AD_2012_R2 # json_path = os.path.join(os.path.dirname(__file__), 'tests', 'ldap_mock.json')
# self._connection.strategy.entries_from_json(json_path)
self.server = ldap3.Server(CONFIG.y('ldap.server.name'), **server_args) self._server = ldap3.Server(source.server_uri) # Implement URI parsing
self.con = ldap3.Connection(self.server, raise_exceptions=True, self._connection = ldap3.Connection(self._server, raise_exceptions=True,
user=CONFIG.y('ldap.bind.username'), user=source.bind_cn,
password=CONFIG.y('ldap.bind.password'), **con_args) password=source.bind_password)
if self.mock: self._connection.bind()
json_path = os.path.join(os.path.dirname(__file__), 'tests', 'ldap_mock.json') # if CONFIG.y('ldap.server.use_tls'):
self.con.strategy.entries_from_json(json_path) # self._connection.start_tls()
self.con.bind()
if CONFIG.y('ldap.server.use_tls'):
self.con.start_tls()
# @staticmethod # @staticmethod
# def cleanup_mock(): # def cleanup_mock():
@ -72,9 +65,9 @@ class LDAPConnector:
# for obj in to_apply: # for obj in to_apply:
# try: # try:
# if obj.action == LDAPModification.ACTION_ADD: # if obj.action == LDAPModification.ACTION_ADD:
# self.con.add(obj.dn, obj.data) # self._connection.add(obj.dn, obj.data)
# elif obj.action == LDAPModification.ACTION_MODIFY: # elif obj.action == LDAPModification.ACTION_MODIFY:
# self.con.modify(obj.dn, obj.data) # self._connection.modify(obj.dn, obj.data)
# # Object has been successfully applied to LDAP # # Object has been successfully applied to LDAP
# obj.delete() # obj.delete()
@ -90,29 +83,31 @@ class LDAPConnector:
# action=action, # action=action,
# data=data) # data=data)
@property # @property
def enabled(self): # def enabled(self):
"""Returns whether LDAP is enabled or not""" # """Returns whether LDAP is enabled or not"""
return CONFIG.y('ldap.enabled') # return CONFIG.y('ldap.enabled')
@staticmethod @staticmethod
def encode_pass(password): def encode_pass(password):
"""Encodes a plain-text password so it can be used by AD""" """Encodes a plain-text password so it can be used by AD"""
return '"{}"'.format(password).encode('utf-16-le') return '"{}"'.format(password).encode('utf-16-le')
def lookup(self, generate_only=False, **fields): def generate_filter(self, **fields):
"""Search email in LDAP and return the DN. """Generate LDAP filter from **fields."""
Returns False if nothing was found."""
filters = [] filters = []
for item, value in fields.items(): for item, value in fields.items():
filters.append("(%s=%s)" % (item, value)) filters.append("(%s=%s)" % (item, value))
ldap_filter = "(&%s)" % "".join(filters) ldap_filter = "(&%s)" % "".join(filters)
LOGGER.debug("Constructed filter: '%s'", ldap_filter) LOGGER.debug("Constructed filter: '%s'", ldap_filter)
if generate_only:
return ldap_filter return ldap_filter
def lookup(self, ldap_filter: str):
"""Search email in LDAP and return the DN.
Returns False if nothing was found."""
try: try:
self.con.search(self.base_dn, ldap_filter) self._connection.search(self._source.search_base, ldap_filter)
results = self.con.response results = self._connection.response
if len(results) >= 1: if len(results) >= 1:
if 'dn' in results[0]: if 'dn' in results[0]:
return str(results[0]['dn']) return str(results[0]['dn'])
@ -167,30 +162,30 @@ class LDAPConnector:
def auth_user(self, password, **filters): def auth_user(self, password, **filters):
"""Try to bind as either user_dn or mail with password. """Try to bind as either user_dn or mail with password.
Returns True on success, otherwise False""" Returns True on success, otherwise False"""
if not LDAPConnector.enabled:
return None
filters.pop('request') filters.pop('request')
if not self._source.enabled:
return None
# FIXME: Adapt user_uid # FIXME: Adapt user_uid
# email = filters.pop(CONFIG.get('passport').get('ldap').get, '') # email = filters.pop(CONFIG.get('passport').get('ldap').get, '')
email = filters.pop('email') email = filters.pop('email')
user_dn = self.lookup(**{LOGIN_FIELD: email}) user_dn = self.lookup(self.generate_filter(**{LOGIN_FIELD: email}))
if not user_dn: if not user_dn:
return None return None
# Try to bind as new user # Try to bind as new user
LOGGER.debug("Binding as '%s'", user_dn) LOGGER.debug("Binding as '%s'", user_dn)
try: try:
t_con = ldap3.Connection(self.server, user=user_dn, temp_connection = ldap3.Connection(self._server, user=user_dn,
password=password, raise_exceptions=True) password=password, raise_exceptions=True)
t_con.bind() temp_connection.bind()
if self.con.search( if self._connection.search(
search_base=self.base_dn, search_base=self._source.search_base,
search_filter=self.lookup(generate_only=True, **{LOGIN_FIELD: email}), search_filter=self.generate_filter(**{LOGIN_FIELD: email}),
search_scope=ldap3.SUBTREE, search_scope=ldap3.SUBTREE,
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES], attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
get_operational_attributes=True, get_operational_attributes=True,
size_limit=1, size_limit=1,
): ):
response = self.con.response[0] response = self._connection.response[0]
# If user has no email set in AD, use UPN # If user has no email set in AD, use UPN
if 'mail' not in response.get('attributes'): if 'mail' not in response.get('attributes'):
response['attributes']['mail'] = response['attributes']['userPrincipalName'] response['attributes']['mail'] = response['attributes']['userPrincipalName']
@ -205,14 +200,14 @@ class LDAPConnector:
def is_email_used(self, mail): def is_email_used(self, mail):
"""Checks whether an email address is already registered in LDAP""" """Checks whether an email address is already registered in LDAP"""
if self.create_users_enabled: if self._source.create_user:
return self.lookup(mail=mail) return self.lookup(self.generate_filter(mail=mail))
return False return False
def create_ldap_user(self, user, raw_password): def create_ldap_user(self, user, raw_password):
"""Creates a new LDAP User from a django user and raw_password. """Creates a new LDAP User from a django user and raw_password.
Returns True on success, otherwise False""" Returns True on success, otherwise False"""
if not self.create_users_enabled: if self._source.create_user:
LOGGER.debug("User creation not enabled") LOGGER.debug("User creation not enabled")
return False return False
# The dn of our new entry/object # The dn of our new entry/object
@ -222,7 +217,7 @@ class LDAPConnector:
username_trunk = username[:20] if len(username) > 20 else username username_trunk = username[:20] if len(username) > 20 else username
# AD doesn't like sAMAccountName's with . at the end # AD doesn't like sAMAccountName's with . at the end
username_trunk = username_trunk[:-1] if username_trunk[-1] == '.' else username_trunk username_trunk = username_trunk[:-1] if username_trunk[-1] == '.' else username_trunk
user_dn = 'cn=' + username + ',' + self.base_dn user_dn = 'cn=' + username + ',' + self._source.search_base
LOGGER.debug('New DN: %s', user_dn) LOGGER.debug('New DN: %s', user_dn)
attrs = { attrs = {
'distinguishedName': str(user_dn), 'distinguishedName': str(user_dn),
@ -233,11 +228,11 @@ class LDAPConnector:
'displayName': str(user.username), 'displayName': str(user.username),
'name': str(user.first_name), 'name': str(user.first_name),
'mail': str(user.email), 'mail': str(user.email),
'userPrincipalName': str(username + '@' + self.domain), 'userPrincipalName': str(username + '@' + self._source.domain),
'objectClass': ['top', 'person', 'organizationalPerson', 'user'], 'objectClass': ['top', 'person', 'organizationalPerson', 'user'],
} }
try: try:
self.con.add(user_dn, attributes=attrs) self._connection.add(user_dn, attributes=attrs)
except ldap3.core.exceptions.LDAPException as exception: except ldap3.core.exceptions.LDAPException as exception:
LOGGER.warning("Failed to create user ('%s'), saved to DB", exception) LOGGER.warning("Failed to create user ('%s'), saved to DB", exception)
# LDAPConnector.handle_ldap_error(user_dn, LDAPModification.ACTION_ADD, attrs) # LDAPConnector.handle_ldap_error(user_dn, LDAPModification.ACTION_ADD, attrs)
@ -246,50 +241,42 @@ class LDAPConnector:
def _do_modify(self, diff, **fields): def _do_modify(self, diff, **fields):
"""Do the LDAP modification itself""" """Do the LDAP modification itself"""
user_dn = self.lookup(**fields) user_dn = self.lookup(self.generate_filter(**fields))
try: try:
self.con.modify(user_dn, diff) self._connection.modify(user_dn, diff)
except ldap3.core.exceptions.LDAPException as exception: except ldap3.core.exceptions.LDAPException as exception:
LOGGER.warning("Failed to modify %s ('%s'), saved to DB", user_dn, exception) LOGGER.warning("Failed to modify %s ('%s'), saved to DB", user_dn, exception)
# LDAPConnector.handle_ldap_error(user_dn, LDAPModification.ACTION_MODIFY, diff) # LDAPConnector.handle_ldap_error(user_dn, LDAPModification.ACTION_MODIFY, diff)
LOGGER.debug("modified account '%s' [%s]", user_dn, ','.join(diff.keys())) LOGGER.debug("modified account '%s' [%s]", user_dn, ','.join(diff.keys()))
return 'result' in self.con.result and self.con.result['result'] == 0 return 'result' in self._connection.result and self._connection.result['result'] == 0
def disable_user(self, **fields): def disable_user(self, **fields):
""" """Disables LDAP user based on mail or user_dn.
Disables LDAP user based on mail or user_dn. Returns True on success, otherwise False"""
Returns True on success, otherwise False
"""
diff = { diff = {
'userAccountControl': [(ldap3.MODIFY_REPLACE, [str(66050)])], 'userAccountControl': [(ldap3.MODIFY_REPLACE, [str(66050)])],
} }
return self._do_modify(diff, **fields) return self._do_modify(diff, **fields)
def enable_user(self, **fields): def enable_user(self, **fields):
""" """Enables LDAP user based on mail or user_dn.
Enables LDAP user based on mail or user_dn. Returns True on success, otherwise False"""
Returns True on success, otherwise False
"""
diff = { diff = {
'userAccountControl': [(ldap3.MODIFY_REPLACE, [str(66048)])], 'userAccountControl': [(ldap3.MODIFY_REPLACE, [str(66048)])],
} }
return self._do_modify(diff, **fields) return self._do_modify(diff, **fields)
def change_password(self, new_password, **fields): def change_password(self, new_password, **fields):
""" """Changes LDAP user's password based on mail or user_dn.
Changes LDAP user's password based on mail or user_dn. Returns True on success, otherwise False"""
Returns True on success, otherwise False
"""
diff = { diff = {
'unicodePwd': [(ldap3.MODIFY_REPLACE, [LDAPConnector.encode_pass(new_password)])], 'unicodePwd': [(ldap3.MODIFY_REPLACE, [LDAPConnector.encode_pass(new_password)])],
} }
return self._do_modify(diff, **fields) return self._do_modify(diff, **fields)
def add_to_group(self, group_dn, **fields): def add_to_group(self, group_dn, **fields):
""" """Adds mail or user_dn to group_dn
Adds mail or user_dn to group_dn Returns True on success, otherwise False"""
Returns True on success, otherwise False
"""
user_dn = self.lookup(**fields) user_dn = self.lookup(**fields)
diff = { diff = {
'member': [(ldap3.MODIFY_ADD), [user_dn]] 'member': [(ldap3.MODIFY_ADD), [user_dn]]
@ -297,10 +284,8 @@ class LDAPConnector:
return self._do_modify(diff, user_dn=group_dn) return self._do_modify(diff, user_dn=group_dn)
def remove_from_group(self, group_dn, **fields): def remove_from_group(self, group_dn, **fields):
""" """Removes mail or user_dn from group_dn
Removes mail or user_dn from group_dn Returns True on success, otherwise False"""
Returns True on success, otherwise False
"""
user_dn = self.lookup(**fields) user_dn = self.lookup(**fields)
diff = { diff = {
'member': [(ldap3.MODIFY_DELETE), [user_dn]] 'member': [(ldap3.MODIFY_DELETE), [user_dn]]

View file

@ -1,200 +0,0 @@
{
"entries": [
{
"attributes": {
"dSCorePropagationData": [
"1601-01-01 00:00:00+00:00"
],
"distinguishedName": "OU=customers,DC=mock,DC=beryju,DC=org",
"instanceType": 4,
"name": "customers_dev",
"objectCategory": "CN=Organizational-Unit,CN=Schema,CN=Configuration,DC=mock,DC=beryju,DC=org",
"objectClass": [
"top",
"organizationalUnit"
],
"objectGUID": "976832bb-f359-4ebc-b7c4-cb6c2ac171cb",
"ou": [
"customers_dev"
],
"uSNChanged": 139575,
"uSNCreated": 139575,
"whenChanged": "2016-12-26 17:08:44+00:00",
"whenCreated": "2016-12-26 17:08:20+00:00"
},
"dn": "OU=customers,DC=mock,DC=beryju,DC=org",
"raw": {
"dSCorePropagationData": [
"16010101000000.0Z"
],
"distinguishedName": [
"OU=customers,DC=mock,DC=beryju,DC=org"
],
"instanceType": [
"4"
],
"name": [
"customers_dev"
],
"objectCategory": [
"CN=Organizational-Unit,CN=Schema,CN=Configuration,DC=mock,DC=beryju,DC=org"
],
"objectClass": [
"top",
"organizationalUnit"
],
"objectGUID": [
{
"encoded": "uzJol1nzvE63xMtsKsFxyw==",
"encoding": "base64"
}
],
"ou": [
"customers_dev"
],
"uSNChanged": [
"139575"
],
"uSNCreated": [
"139575"
],
"whenChanged": [
"20161226170844.0Z"
],
"whenCreated": [
"20161226170820.0Z"
]
}
},
{
"attributes": {
"accountExpires": "9999-12-31 23:59:59.999999",
"cn": "mockadm",
"codePage": 0,
"countryCode": 0,
"dSCorePropagationData": [
"1601-01-01 00:00:00+00:00"
],
"description": [
"t=1484309644.2392948"
],
"displayName": "mockadm",
"distinguishedName": "CN=mockadm,OU=customers,DC=mock,DC=beryju,DC=org",
"givenName": "admin@admin.admin",
"instanceType": 4,
"mail": "mockadm@mock.beryju.org",
"name": "mockadm",
"objectCategory": "CN=Person,CN=Schema,CN=Configuration,DC=mock,DC=beryju,DC=org",
"objectClass": [
"top",
"person",
"organizationalPerson",
"user"
],
"objectGUID": "d28cd23f-a3bc-40a3-93e4-f47b344197c1",
"objectSid": "S-1-5-21-3376105463-1408393234-2945003003-2175",
"primaryGroupID": 513,
"pwdLastSet": "2017-01-13 12:14:04.251018+00:00",
"sAMAccountName": "mockadm",
"sAMAccountType": 805306368,
"uSNChanged": 179076,
"uSNCreated": 179076,
"userAccountControl": 66050,
"userPrincipalName": "mockadm@mock.beryju.org",
"whenChanged": "2017-01-13 12:27:52+00:00",
"whenCreated": "2017-01-13 12:14:04+00:00",
"userPassword": "b3ryju0rg!"
},
"dn": "CN=mockadm,OU=customers,DC=mock,DC=beryju,DC=org",
"raw": {
"accountExpires": [
"9223372036854775807"
],
"cn": [
"mockadm"
],
"codePage": [
"0"
],
"countryCode": [
"0"
],
"dSCorePropagationData": [
"16010101000000.0Z"
],
"description": [
"t=1484309644.2392948"
],
"displayName": [
"mockadm"
],
"distinguishedName": [
"CN=mockadm,OU=customers,DC=mock,DC=beryju,DC=org"
],
"givenName": [
"admin@admin.admin"
],
"instanceType": [
"4"
],
"mail": [
"admin@admin.admin"
],
"name": [
"mockadm"
],
"objectCategory": [
"CN=Person,CN=Schema,CN=Configuration,DC=mock,DC=beryju,DC=org"
],
"objectClass": [
"top",
"person",
"organizationalPerson",
"user"
],
"objectGUID": [
{
"encoded": "P9KM0ryjo0CT5PR7NEGXwQ==",
"encoding": "base64"
}
],
"objectSid": [
{
"encoded": "AQUAAAAAAAUVAAAA90c7yRJg8lP7LYmvfwgAAA==",
"encoding": "base64"
}
],
"primaryGroupID": [
"513"
],
"sAMAccountName": [
"mockadm"
],
"sAMAccountType": [
"805306368"
],
"uSNChanged": [
"179076"
],
"uSNCreated": [
"179076"
],
"userAccountControl": [
"66050"
],
"userPrincipalName": [
"mockadm@mock.beryju.org"
],
"whenChanged": [
"20170113122752.0Z"
],
"whenCreated": [
"20170113121404.0Z"
],
"userPassword": [
"b3ryju0rg!"
]
}
}
]
}

View file

@ -1,53 +0,0 @@
"""passbook ldap settings"""
import os
from django.test import TestCase
from passbook.core.models import User
# from supervisr.mod.auth.ldap.forms import GeneralSettingsForm
from passbook.ldap.ldap_connector import LDAPConnector
class TestAccountLDAP(TestCase):
"""passbook ldap settings"""
def setUp(self):
os.environ['RECAPTCHA_TESTING'] = 'True'
# FIXME: Loading mock settings from different config file
# Setting.set('domain', 'mock.beryju.org')
# Setting.set('base', 'OU=customers,DC=mock,DC=beryju,DC=org')
# Setting.set('server', 'dc1.mock.beryju.org')
# Setting.set('server:tls', False)
# Setting.set('mode', GeneralSettingsForm.MODE_CREATE_USERS)
# Setting.set('bind:user', 'CN=mockadm,OU=customers,DC=mock,DC=beryju,DC=org')
# Setting.set('bind:password', 'b3ryju0rg!')
self.ldap = LDAPConnector(mock=True)
self.password = 'b3ryju0rg!'
self.user = User.objects.create_user(
username='test@test.test',
email='test@test.test',
first_name='Test user')
self.user.save()
self.user.is_active = False
self.user.set_password(self.password)
self.user.save()
self.assertTrue(self.ldap.create_ldap_user(self.user, self.password))
def test_change_password(self):
"""Test ldap change_password"""
self.assertTrue(self.ldap.change_password('b4ryju1rg!', mail=self.user.email))
self.assertTrue(self.ldap.change_password('b3ryju0rg!', mail=self.user.email))
def test_disable_enable(self):
"""Test ldap enable and disable"""
self.assertTrue(self.ldap.disable_user(mail=self.user.email))
self.assertTrue(self.ldap.enable_user(mail=self.user.email))
def test_email_used(self):
"""Test ldap is_email_used"""
self.assertTrue(self.ldap.is_email_used(self.user.email))
def test_auth(self):
"""Test ldap auth"""
# self.assertTrue(self.ldap.auth_user(self.password, mail=self.user.email))

View file

@ -61,30 +61,36 @@ passbook:
remember_age: 2592000 # 60 * 60 * 24 * 30, one month remember_age: 2592000 # 60 * 60 * 24 * 30, one month
# Provider-specific settings # Provider-specific settings
ldap: ldap:
# Completely enable or disable LDAP provider # # Completely enable or disable LDAP provider
enabled: false # enabled: false
# AD Domain, used to generate `userPrincipalName` # # AD Domain, used to generate `userPrincipalName`
domain: corp.contoso.com # domain: corp.contoso.com
# Base DN in which passbook should look for users # # Base DN in which passbook should look for users
base_dn: dn=corp,dn=contoso,dn=com # base_dn: dn=corp,dn=contoso,dn=com
# LDAP field which is used to set the django username # # LDAP field which is used to set the django username
username_field: sAMAccountName # username_field: sAMAccountName
# LDAP server to connect to, can be set to `<domain_name>` # # LDAP server to connect to, can be set to `<domain_name>`
server: # server:
name: corp.contoso.com # name: corp.contoso.com
use_tls: false # use_tls: false
# Bind credentials, used for account creation # # Bind credentials, used for account creation
bind: # bind:
username: Administraotr@corp.contoso.com # username: Administraotr@corp.contoso.com
password: VerySecurePassword! # password: VerySecurePassword!
# Which field from `uid_fields` maps to which LDAP Attribute # Which field from `uid_fields` maps to which LDAP Attribute
login_field_map: login_field_map:
username: sAMAccountName username: sAMAccountName
email: mail # or userPrincipalName email: mail # or userPrincipalName
# Create new users in LDAP upon sign-up user_attribute_map:
create_users: true active_directory:
# Reset LDAP password when user reset their password sAMAccountName: username
reset_password: true mail: email
given_name: first_name
name: last_name
# # Create new users in LDAP upon sign-up
# create_users: true
# # Reset LDAP password when user reset their password
# reset_password: true
oauth_client: oauth_client:
# List of python packages with sources types to load. # List of python packages with sources types to load.
types: types: