authentic/authentic2/backends.py

635 lines
24 KiB
Python

import ldap
import ldap.modlist
import ldap.sasl
from ldap.filter import filter_format
import logging
import random
import urlparse
import pickle
import base64
import hashlib
# code copied from http://www.amherst.k12.oh.us/django-ldap.html
log = logging.getLogger(__name__)
from django.core.exceptions import ImproperlyConfigured
from django.conf import settings
from django.contrib.auth.models import Group, Permission
from django.db import IntegrityError
from .cache import get_shared_cache
try:
import lasso
except ImportError:
pass
from .compat import get_user_model
from .models import UserExternalId
_DEFAULTS = {
'binddn': None,
'bindpw': None,
'bindsasl': (),
'user_dn_template': None,
'user_filter': 'uid=%s',
'group_dn_template': None,
'member_of_attribute': None,
'group_filter': '(&(member={user_dn})(objectClass=groupOfNames))',
'group': None,
'groupsu': None,
'groupstaff': None,
'groupactive': None,
'group_mapping': (),
'replicas': True,
'email_field': 'mail',
'fname_field': 'givenName',
'lname_field': 'sn',
'timeout': 1,
'disable_update': False,
'use_for_data' : None,
'bind_with_username': False,
# always use the first URL to build the external id
'use_first_url_for_external_id': True,
# do not try to get a Django user from the LDAP user
# it's incompatible with a lot of Django applications, the
# django.contrib.admin for example
'transient': True,
# active directory ?
'active_directory': False,
# shuffle replicas
'shuffle_replicas': True,
# all users from this LDAP are superusers
'is_superuser': False,
# all users from this LDAP are staff
'is_staff': False,
# all users from this LDAP are active
'is_active': True,
# create missing group if needed
'create_group': False,
# attributes to retrieve and store with the user object
'attributes': [],
# default value for some attributes
'mandatory_attributes_values': {},
# mapping from LDAP attributes name to other names
'attribute_mappings': [],
}
_REQUIRED = ('url', 'basedn')
_TO_ITERABLE = ('url', 'groupsu', 'groupstaff', 'groupactive')
def get_connection(block, credentials=()):
if not block['url']:
raise ImproperlyConfigured("block['url'] must contain at least one url")
for url in block['url']:
conn = ldap.initialize(url)
try:
conn.whoami_s()
except ldap.SERVER_DOWN:
if block['replicas']:
log.warning('ldap %r is down', url)
else:
log.error('ldap %r is down', url)
continue
try:
if credentials:
conn.bind_s(*credentials)
elif block['binddn'] and block['bindpw']:
conn.bind_s(block['binddn'], block['bindpw'])
break
except ldap.INVALID_CREDENTIALS:
if block['replicas']:
return None
else:
return None
return conn
def ad_encoding(s):
'''Encode an unicode string for AD consumption as a password'''
return (u'"{0}"'.format(s)).encode('utf-16-le')
def modify_password(conn, block, dn, old_password, new_password):
'''Change user password with adaptation for Active Directory'''
if block['active_directory']:
old_entry = { 'unicodePwd': [ ad_encoding(old_password) ] }
new_entry = { 'unicodePwd': [ ad_encoding(new_password) ] }
else:
old_entry = { 'userPassword': [ old_password.encode('utf-8') ] }
new_entry = { 'userPassword': [ new_password.encode('utf-8') ] }
conn.modify_s(dn, ldap.modlist.modifyModlist(old_entry, new_entry))
log.debug('modified password for dn %r', dn)
class LDAPException(Exception):
pass
class LDAPUser(object):
is_staff = False
is_superuser = False
attributes = {}
USERNAME_FIELD = 'dn'
def __init__(self, block, dn, password):
self.block = block
self.dn = dn
self.is_active = True
self.set_ldap_password(password)
self.groups = set()
def set_ldap_password(self, password):
shared_cache = get_shared_cache('ldap')
shared_cache.set(self.get_ldap_password_cache_key(), password, 86400)
def get_cache_key(self):
return hashlib.md5(self.dn).hexdigest()
def get_ldap_password_cache_key(self):
return 'ldap-password-{0}'.format(self.get_cache_key())
def get_password(self):
shared_cache = get_shared_cache('ldap')
password = shared_cache.get(self.get_ldap_password_cache_key())
if password is None:
raise LDAPException('missing password for dn %r', self.dn)
return password
def get_username(self):
return self.dn
def get_full_name(self):
block = self.block
if block['fname_field'] and block['lname_field']:
return u'{first_name} {last_name}'.format(**self.__dict__)
if block['email_field']:
return self.email
return self.get_username()
def get_short_name(self):
block = self.block
if block['fname_field']:
return self.first_name
if block['email_field']:
return self.email
dn = self.dn
l = dn.split(',', 1)[0].split('=')
if len(l) > 1:
return l[1]
return l[0]
def is_authenticated(self):
return True
def is_anonymous(self):
return False
def set_password(self, new_password):
conn = self.get_connection()
modify_password(conn, self.block, self.dn, self.get_password(),
new_password)
self.set_ldap_password(new_password)
def set_unusable_password(self):
raise NotImplementedError
def has_usable_password(self):
return True
def get_connection(self):
return get_connection(self.block, (self.dn, self.get_password()))
def save(self, **kwargs):
pass
def get_group_permissions(self, obj=None):
if not hasattr(self, '_group_perm_cache'):
if self.is_superuser:
perms = Permission.objects.all()
else:
group_pks = (group.pk for group in self.groups)
perms = Permission.objects.filter(group__pk__in=group_pks)
perms = perms.values_list('content_type__app_label', 'codename').order_by()
self._group_perm_cache = set(["%s.%s" % (ct, name) for ct, name in perms])
return self._group_perm_cache
def get_all_permissions(self, obj=None):
return self.get_group_permissions(obj)
def has_perm(self, perm, obj=None):
if not self.is_active:
return False
return perm in self.get_all_permissions(obj)
def has_perms(self, perm_list, obj=None):
pass
def has_module_perms(self, app_label):
if not self.is_active:
return False
for perm in self.get_all_permissions():
if perm[:perm.index('.')] == app_label:
return True
return False
def get_principal(self):
return self.dn
def get_attributes(self):
return self.attributes
class LDAPBackendError(Exception):
pass
class LDAPBackendError(RuntimeError):
pass
class LDAPBackend():
def get_blocks(self):
if isinstance(settings.LDAP_AUTH_SETTINGS[0], dict):
log.debug('Using complex settings')
blocks = settings.LDAP_AUTH_SETTINGS
else:
log.debug('Using simple settings')
blocks = (self._parse_simple_config(),)
return blocks
def authenticate(self, username=None, password=None):
if username is None or password is None:
return None
blocks = self.get_blocks()
# First get our configuration into a standard format
for block in blocks:
for r in _REQUIRED:
if r not in block:
raise ImproperlyConfigured('LDAP_AUTH_SETTINGS: missing required configuration option %r' % r)
for d in _DEFAULTS:
if d not in block:
block[d] = _DEFAULTS[d]
else:
if isinstance(_DEFAULTS[d], basestring):
if not isinstance(block[d], basestring):
raise ImproperlyConfigured('LDAP_AUTH_SETTINGS: '
'attribute %r must be a string' % d)
try:
block[d] = str(block[d])
except UnicodeEncodeError:
raise ImproperlyConfigured('LDAP_AUTH_SETTINGS: '
'attribute %r must be a string' % d)
if isinstance(_DEFAULTS[d], bool) and not isinstance(block[d], bool):
raise ImproperlyConfigured('LDAP_AUTH_SETTINGS: '
'attribute %r must be a boolean' % d)
if isinstance(_DEFAULTS[d], (list, tuple)) and not isinstance(block[d], (list, tuple)):
raise ImproperlyConfigured('LDAP_AUTH_SETTINGS: '
'attribute %r must be a list or a tuple' % d)
if isinstance(_DEFAULTS[d], dict) and not isinstance(block[d], dict):
raise ImproperlyConfigured('LDAP_AUTH_SETTINGS: '
'attribute %r must be a dictionary' % d)
for i in _TO_ITERABLE:
if isinstance(block[i], basestring):
block[i] = (block[i],)
# Want to randomize our access, otherwise what's the point of having multiple servers?
block['url'] = list(block['url'])
if block['shuffle_replicas']:
random.shuffle(block['url'])
# Now we can try to authenticate
for block in blocks:
try:
user = self.authenticate_block(block, username, password)
if user is not None:
return user
except:
log.exception('unexpected exception')
raise
def authenticate_block(self, block, username, password):
for opt in ('NETWORK_TIMEOUT', 'TIMELIMIT', 'TIMEOUT'):
ldap.set_option(getattr(ldap, 'OPT_%s' % opt), block['timeout'])
utf8_username = username.encode('utf-8')
utf8_password = password.encode('utf-8')
for uri in block['url']:
log.debug('try to bind user on %r', uri)
conn = ldap.initialize(uri)
authz_id = None
user_basedn = block.get('user_basedn', block['basedn'])
try:
# if necessary bind as admin
self.try_admin_bind(conn, block)
if block['user_dn_template']:
authz_id = block['user_dn_template'].format(username=username)
else:
try:
if block.get('bind_with_username'):
authz_id = utf8_username
elif block['user_filter']:
try:
query = filter_format(block['user_filter'], (utf8_username,))
except TypeError, e:
log.error('user_filter syntax error %r: %s',
block['user_filter'], e)
return
log.debug('looking up dn for username %r using '
'query %r', username, query)
results = conn.search_s(user_basedn, ldap.SCOPE_SUBTREE, query)
if len(results) == 0:
log.debug('user bind failed: not entry found')
elif len(results) > 1:
log.warning('user bind failed: too many (%d) '
'entries found', len(results))
else:
authz_id = results[0][0]
else:
raise NotImplementedError
except ldap.NO_SUCH_OBJECT:
log.error('user bind failed: unable to lookup user '
'basedn %s not found', user_basedn)
if block['replicas']:
break
continue
except ldap.LDAPError, e:
log.error('user bind failed: unable to lookup user %r: '
'%s', username, e)
continue
if authz_id is None:
continue
try:
conn.simple_bind_s(authz_id, utf8_password)
except ldap.INVALID_CREDENTIALS:
log.debug('user bind failed: invalid credentials' % uri)
if block['replicas']:
break
continue
except ldap.LDAPError, e:
log.error('Got error from LDAP library: %s' % str(e))
return None
return self._return_user(uri, authz_id, username, password, conn, block)
except ldap.SERVER_DOWN:
log.error('ldap authentication error: %r is down', uri)
finally:
del conn
return None
def get_user(self, user_id):
if hasattr(user_id, 'startswith') and user_id.startswith('transient!'):
user = pickle.loads(base64.b64decode(user_id[len('transient!'):]))
else:
try:
user = get_user_model().objects.get(pk=user_id)
except:
pass
else:
shared_cache = get_shared_cache('ldap')
value = shared_cache.get('ldap-pk-{0}'.format(user_id))
if value is not None:
uri, dn, username, password, block, attributes = value
user.ldap_uri = uri
user.ldap_dn = dn
user.ldap_username = username
user.ldap_password = password
user.ldap_block = block
user.get_attributes = lambda: attributes
return user
def _parse_simple_config(self):
if len(settings.LDAP_AUTH_SETTINGS) < 2:
raise LDAPBackendError('In a minimal configuration, you must at least specify url and user DN')
return {'url': settings.LDAP_AUTH_SETTINGS[0], 'basedn': settings.LDAP_AUTH_SETTINGS[1]}
def backend_name(self):
return '%s.%s' % (__name__, self.__class__.__name__)
def build_ldap_external_id(self, uri, dn, block):
if block['use_first_url_for_external_id']:
uri = block['url'][0]
parsed_uri = urlparse.urlparse(uri)
return '{scheme}://{netloc}/{dn}??one?'.format(scheme=parsed_uri.scheme,
netloc=parsed_uri.netloc, dn=dn)
def create_user(self, uri, dn, username, password, conn, block):
User = get_user_model()
new_user_username = username
count = 0
while True:
try:
user = User.objects.create(username=new_user_username)
break
except IntegrityError:
new_user_username = u'{0}-{1}'.format(username, count)
count += 1
if block['replicas']:
uri = block['url'][0]
UserExternalId.objects.create(user=user, source=uri, external_id=dn)
user.set_unusable_password()
user.save()
return user
def populate_user_attributes(self, user, uri, dn, conn, block):
try:
results = conn.search_s(dn, ldap.SCOPE_BASE, '(objectclass=*)',
[block['email_field'], block['fname_field'],
block['lname_field']])
except ldap.LDAPError, e:
log.warning('unable to retrieve attributes of user with dn %r '
'from server %r: %s', dn, uri, e)
if len(results) > 1:
log.warning('unable to retrieve attributes of user with dn %r '
'from server %r: too many records', dn, uri)
attributes = results[0][1]
for legacy_attribute, legacy_field in (('email', 'email_field'),
('first_name', 'fname_field'), ('last_name', 'lname_field')):
ldap_attribute = block[legacy_field]
if not ldap_attribute:
break
if ldap_attribute in attributes:
value = attributes[ldap_attribute][0]
else:
value = u''
setattr(user, legacy_attribute, value)
def try_admin_bind(self, conn, block):
try:
if block['bindsasl']:
sasl_mech, who, sasl_params = block['bindsasl']
handler_class = getattr(ldap.sasl, sasl_mech)
auth = handler_class(*sasl_params)
conn.sasl_interactive_bind_s(who, auth)
elif block['binddn'] and block['bindpw']:
conn.simple_bind_s(block['binddn'], block['bindpw'])
except ldap.INVALID_CREDENTIALS:
log.error('admin bind failed: invalid credentials (%r, %r)',
block['binddn'], '*hidden*')
except ldap.INVALID_DN_SYNTAX:
log.error('admin bind failed: invalid dn syntax %r', who)
else:
return True
return False
def populate_admin_flags_by_group(self, user, uri, dn, conn, block, group_dns):
'''Attribute admin flags based on groups.
It supersedes is_staff, is_superuser and is_active.'''
for g, attr in (('groupsu', 'is_superuser'), ('groupstaff', 'is_staff'), ('groupactive', 'is_active')):
group_dns_to_match = block[g]
if not group_dns_to_match:
continue
for group_dn in group_dns_to_match:
if group_dn in group_dns:
setattr(user, attr, True)
break
else:
setattr(user, attr, False)
def populate_groups_by_mapping(self, user, uri, dn, conn, block, group_dns):
'''Assign group to user based on a mapping from group DNs'''
group_mapping = block['group_mapping']
if not group_mapping:
return
for dn, group_names in group_mapping:
method = user.groups.add if dn in group_dns else user.groups.remove
for group_name in group_names:
group = self.get_group_by_name(block, group_name)
if group is not None:
try:
method(group)
except KeyError:
pass
def get_ldap_group_dns(self, user, uri, dn, conn, block):
'''Retrieve group DNs from the LDAP by attributes (memberOf) or by
filter.
'''
group_base_dn = block.get('group_basedn', block['basedn'])
member_of_attribute = block['member_of_attribute']
group_filter = block['group_filter']
group_dns = set()
if member_of_attribute:
results = conn.search_s(dn, ldap.SCOPE_BASE, '', [member_of_attribute])
group_dns.update(results[0][1].get(member_of_attribute, []))
if group_filter:
try:
results = conn.search_s(group_base_dn, ldap.SCOPE_SUBTREE,
group_filter.format(user_dn=dn), [])
except ldap.NO_SUCH_OBJECT:
pass
group_dns.update(dn for dn, attributes in results)
return group_dns
def populate_user_groups(self, user, uri, dn, conn, block):
group_dns = self.get_ldap_group_dns(user, uri, dn, conn, block)
log.debug('groups for dn %r: %r', dn, group_dns)
self.populate_admin_flags_by_group(user, uri, dn, conn, block, group_dns)
self.populate_groups_by_mapping(user, uri, dn, conn, block, group_dns)
def get_group_by_name(self, block, group_name, create=None):
'''Obtain a Django group'''
if create is None:
create = block['create_group']
if create:
group, created = Group.objects.get_or_create(name=group_name)
return group
else:
try:
return Group.objects.get(name=group_name)
except Group.ObjectDoesNotExist:
return None
def populate_mandatory_groups(self, user, uri, dn, conn, block):
mandatory_groups = block.get('set_mandatory_groups')
if not mandatory_groups:
return
for group_name in mandatory_groups:
group = self.get_group_by_name(block, group_name)
if group:
user.groups.add(group)
def populate_admin_fields(self, user, uri, dn, conn, block):
user.is_active = block['is_active']
user.is_staff = block['is_staff']
user.is_superuser = block['is_superuser']
def populate_user(self, user, uri, dn, conn, block):
self.populate_user_attributes(user, uri, dn, conn, block)
self.populate_admin_fields(user, uri, dn, conn, block)
self.populate_mandatory_groups(user, uri, dn, conn, block)
self.populate_user_groups(user, uri, dn, conn, block)
def get_ldap_attributes(self, uri, dn, conn, block):
'''Retrieve some attributes from LDAP, add mandatory values then apply
defined mappings between atrribute names'''
attributes = map(str, block['attributes'])
attribute_mappings = block['attribute_mappings']
mandatory_attributes_values = block['mandatory_attributes_values']
try:
results = conn.search_s(dn, ldap.SCOPE_BASE, '(objectclass=*)', attributes)
except ldap.LDAPError, e:
log.warning('unable to retrieve attributes of user with dn %r '
'from server %r: %s', dn, uri, e)
attribute_map = results[0][1]
# add mandatory attributes
for key, mandatory_values in mandatory_attributes_values.iteritems():
key = str(key)
old = attribute_map.setdefault(key, [])
new = set(old) | set(mandatory_values)
attribute_map[key] = list(new)
# apply mappings
for from_attribute, to_attribute in attribute_mappings:
if from_attribute not in attribute_map:
continue
to_attribute = str(to_attribute)
old = attribute_map.setdefault(to_attribute, [])
new = set(old) | set(attribute_map[from_attribute])
attribute_map[to_attribute] = list(new)
return attribute_map
def _return_user(self, uri, dn, username, password, conn, block):
if block['transient']:
return self._return_transient_user(uri, dn, username, password, conn, block)
else:
return self._return_django_user(uri, dn, username, password, conn, block)
def _return_transient_user(self, uri, dn, username, password, conn, block):
user = LDAPUser(block=block, dn=dn, password=password)
self.populate_user(user, uri, dn, conn, block)
user.pk = 'transient!{0}'.format(base64.b64encode(pickle.dumps(user)))
return user
def _return_django_user(self, uri, dn, username, password, conn, block):
if block['replicas']:
uri = block['url'][0]
user_external_ids = UserExternalId.objects.filter(source=uri, external_id=dn)
count = len(user_external_ids)
if count == 0:
log.info('creating user %r with dn %r from server %r', username, dn, uri)
user = self.create_user(uri, dn, username, password, conn, block)
elif count == 1:
user = user_external_ids[0].user
log.debug('found user %r for dn %r from server %r', user, dn, uri)
else:
raise NotImplementedError
attributes = self.get_ldap_attributes(uri, dn, conn, block)
log.debug('retrieved attributes for %r: %r', dn, attributes)
self.populate_user(user, uri, dn, conn, block)
shared_cache = get_shared_cache('ldap')
shared_cache.set('ldap-pk-{0}'.format(user.pk), (uri, dn, username,
password, block, attributes), 86400)
user.save()
return user
def has_usable_password(self, user):
return True
def get_saml2_authn_context(self):
return lasso.SAML2_AUTHN_CONTEXT_PASSWORD_PROTECTED_TRANSPORT