cubicweb/server/sources/ldapfeed.py
changeset 11057 0b59724cb3f2
parent 10913 5d7f17054ae6
child 11279 e4f11ef1face
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
       
     1 # copyright 2003-2015 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """cubicweb ldap feed source"""
       
    19 
       
    20 from __future__ import division  # XXX why?
       
    21 
       
    22 from datetime import datetime
       
    23 
       
    24 from six import PY2, string_types
       
    25 
       
    26 import ldap3
       
    27 
       
    28 from logilab.common.configuration import merge_options
       
    29 
       
    30 from cubicweb import ValidationError, AuthenticationError, Binary
       
    31 from cubicweb.server import utils
       
    32 from cubicweb.server.sources import datafeed
       
    33 
       
    34 from cubicweb import _
       
    35 
       
    36 # search scopes
       
    37 BASE = ldap3.SEARCH_SCOPE_BASE_OBJECT
       
    38 ONELEVEL = ldap3.SEARCH_SCOPE_SINGLE_LEVEL
       
    39 SUBTREE = ldap3.SEARCH_SCOPE_WHOLE_SUBTREE
       
    40 LDAP_SCOPES = {'BASE': BASE,
       
    41                'ONELEVEL': ONELEVEL,
       
    42                'SUBTREE': SUBTREE}
       
    43 
       
    44 # map ldap protocol to their standard port
       
    45 PROTO_PORT = {'ldap': 389,
       
    46               'ldaps': 636,
       
    47               'ldapi': None,
       
    48               }
       
    49 
       
    50 
       
    51 def replace_filter(s):
       
    52     s = s.replace('*', '\\2A')
       
    53     s = s.replace('(', '\\28')
       
    54     s = s.replace(')', '\\29')
       
    55     s = s.replace('\\', '\\5c')
       
    56     s = s.replace('\0', '\\00')
       
    57     return s
       
    58 
       
    59 
       
    60 class LDAPFeedSource(datafeed.DataFeedSource):
       
    61     """LDAP feed source: unlike ldapuser source, this source is copy based and
       
    62     will import ldap content (beside passwords for authentication) into the
       
    63     system source.
       
    64     """
       
    65     support_entities = {'CWUser': False}
       
    66     use_cwuri_as_url = False
       
    67 
       
    68     options = (
       
    69         ('auth-mode',
       
    70          {'type' : 'choice',
       
    71           'default': 'simple',
       
    72           'choices': ('simple', 'digest_md5', 'gssapi'),
       
    73           'help': 'authentication mode used to authenticate user to the ldap.',
       
    74           'group': 'ldap-source', 'level': 3,
       
    75           }),
       
    76         ('auth-realm',
       
    77          {'type' : 'string',
       
    78           'default': None,
       
    79           'help': 'realm to use when using gssapi/kerberos authentication.',
       
    80           'group': 'ldap-source', 'level': 3,
       
    81           }),
       
    82 
       
    83         ('data-cnx-dn',
       
    84          {'type' : 'string',
       
    85           'default': '',
       
    86           'help': 'user dn to use to open data connection to the ldap (eg used \
       
    87 to respond to rql queries). Leave empty for anonymous bind',
       
    88           'group': 'ldap-source', 'level': 1,
       
    89           }),
       
    90         ('data-cnx-password',
       
    91          {'type' : 'string',
       
    92           'default': '',
       
    93           'help': 'password to use to open data connection to the ldap (eg used to respond to rql queries). Leave empty for anonymous bind.',
       
    94           'group': 'ldap-source', 'level': 1,
       
    95           }),
       
    96 
       
    97         ('user-base-dn',
       
    98          {'type' : 'string',
       
    99           'default': '',
       
   100           'help': 'base DN to lookup for users; disable user importation mechanism if unset',
       
   101           'group': 'ldap-source', 'level': 1,
       
   102           }),
       
   103         ('user-scope',
       
   104          {'type' : 'choice',
       
   105           'default': 'ONELEVEL',
       
   106           'choices': ('BASE', 'ONELEVEL', 'SUBTREE'),
       
   107           'help': 'user search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")',
       
   108           'group': 'ldap-source', 'level': 1,
       
   109           }),
       
   110         ('user-classes',
       
   111          {'type' : 'csv',
       
   112           'default': ('top', 'posixAccount'),
       
   113           'help': 'classes of user (with Active Directory, you want to say "user" here)',
       
   114           'group': 'ldap-source', 'level': 1,
       
   115           }),
       
   116         ('user-filter',
       
   117          {'type': 'string',
       
   118           'default': '',
       
   119           'help': 'additional filters to be set in the ldap query to find valid users',
       
   120           'group': 'ldap-source', 'level': 2,
       
   121           }),
       
   122         ('user-login-attr',
       
   123          {'type' : 'string',
       
   124           'default': 'uid',
       
   125           'help': 'attribute used as login on authentication (with Active Directory, you want to use "sAMAccountName" here)',
       
   126           'group': 'ldap-source', 'level': 1,
       
   127           }),
       
   128         ('user-default-group',
       
   129          {'type' : 'csv',
       
   130           'default': ('users',),
       
   131           'help': 'name of a group in which ldap users will be by default. \
       
   132 You can set multiple groups by separating them by a comma.',
       
   133           'group': 'ldap-source', 'level': 1,
       
   134           }),
       
   135         ('user-attrs-map',
       
   136          {'type' : 'named',
       
   137           'default': {'uid': 'login'},
       
   138           'help': 'map from ldap user attributes to cubicweb attributes (with Active Directory, you want to use sAMAccountName:login,mail:email,givenName:firstname,sn:surname)',
       
   139           'group': 'ldap-source', 'level': 1,
       
   140           }),
       
   141         ('group-base-dn',
       
   142          {'type' : 'string',
       
   143           'default': '',
       
   144           'help': 'base DN to lookup for groups; disable group importation mechanism if unset',
       
   145           'group': 'ldap-source', 'level': 1,
       
   146           }),
       
   147         ('group-scope',
       
   148          {'type' : 'choice',
       
   149           'default': 'ONELEVEL',
       
   150           'choices': ('BASE', 'ONELEVEL', 'SUBTREE'),
       
   151           'help': 'group search scope (valid values: "BASE", "ONELEVEL", "SUBTREE")',
       
   152           'group': 'ldap-source', 'level': 1,
       
   153           }),
       
   154         ('group-classes',
       
   155          {'type' : 'csv',
       
   156           'default': ('top', 'posixGroup'),
       
   157           'help': 'classes of group',
       
   158           'group': 'ldap-source', 'level': 1,
       
   159           }),
       
   160         ('group-filter',
       
   161          {'type': 'string',
       
   162           'default': '',
       
   163           'help': 'additional filters to be set in the ldap query to find valid groups',
       
   164           'group': 'ldap-source', 'level': 2,
       
   165           }),
       
   166         ('group-attrs-map',
       
   167          {'type' : 'named',
       
   168           'default': {'cn': 'name', 'memberUid': 'member'},
       
   169           'help': 'map from ldap group attributes to cubicweb attributes',
       
   170           'group': 'ldap-source', 'level': 1,
       
   171           }),
       
   172     )
       
   173 
       
   174     options = merge_options(datafeed.DataFeedSource.options + options,
       
   175                             optgroup='ldap-source',)
       
   176 
       
   177     _conn = None
       
   178 
       
   179     def update_config(self, source_entity, typedconfig):
       
   180         """update configuration from source entity. `typedconfig` is config
       
   181         properly typed with defaults set
       
   182         """
       
   183         super(LDAPFeedSource, self).update_config(source_entity, typedconfig)
       
   184         self.authmode = typedconfig['auth-mode']
       
   185         self._authenticate = getattr(self, '_auth_%s' % self.authmode)
       
   186         self.cnx_dn = typedconfig['data-cnx-dn']
       
   187         self.cnx_pwd = typedconfig['data-cnx-password']
       
   188         self.user_base_dn = str(typedconfig['user-base-dn'])
       
   189         self.user_base_scope = globals()[typedconfig['user-scope']]
       
   190         self.user_login_attr = typedconfig['user-login-attr']
       
   191         self.user_default_groups = typedconfig['user-default-group']
       
   192         self.user_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
       
   193         self.user_attrs.update(typedconfig['user-attrs-map'])
       
   194         self.user_rev_attrs = dict((v, k) for k, v in self.user_attrs.items())
       
   195         self.base_filters = ['(objectclass=%s)' % replace_filter(o)
       
   196                              for o in typedconfig['user-classes']]
       
   197         if typedconfig['user-filter']:
       
   198             self.base_filters.append(typedconfig['user-filter'])
       
   199         self.group_base_dn = str(typedconfig['group-base-dn'])
       
   200         self.group_base_scope = LDAP_SCOPES[typedconfig['group-scope']]
       
   201         self.group_attrs = typedconfig['group-attrs-map']
       
   202         self.group_attrs = {'dn': 'eid', 'modifyTimestamp': 'modification_date'}
       
   203         self.group_attrs.update(typedconfig['group-attrs-map'])
       
   204         self.group_rev_attrs = dict((v, k) for k, v in self.group_attrs.items())
       
   205         self.group_base_filters = ['(objectClass=%s)' % replace_filter(o)
       
   206                                    for o in typedconfig['group-classes']]
       
   207         if typedconfig['group-filter']:
       
   208             self.group_base_filters.append(typedconfig['group-filter'])
       
   209         self._conn = None
       
   210 
       
   211     def _entity_update(self, source_entity):
       
   212         super(LDAPFeedSource, self)._entity_update(source_entity)
       
   213         if self.urls:
       
   214             if len(self.urls) > 1:
       
   215                 raise ValidationError(source_entity.eid, {'url': _('can only have one url')})
       
   216             try:
       
   217                 protocol, hostport = self.urls[0].split('://')
       
   218             except ValueError:
       
   219                 raise ValidationError(source_entity.eid, {'url': _('badly formatted url')})
       
   220             if protocol not in PROTO_PORT:
       
   221                 raise ValidationError(source_entity.eid, {'url': _('unsupported protocol')})
       
   222 
       
   223     def connection_info(self):
       
   224         assert len(self.urls) == 1, self.urls
       
   225         protocol, hostport = self.urls[0].split('://')
       
   226         if protocol != 'ldapi' and ':' in hostport:
       
   227             host, port = hostport.rsplit(':', 1)
       
   228         else:
       
   229             host, port = hostport, PROTO_PORT[protocol]
       
   230         return protocol, host, port
       
   231 
       
   232     def authenticate(self, cnx, login, password=None, **kwargs):
       
   233         """return CWUser eid for the given login/password if this account is
       
   234         defined in this source, else raise `AuthenticationError`
       
   235 
       
   236         two queries are needed since passwords are stored crypted, so we have
       
   237         to fetch the salt first
       
   238         """
       
   239         self.info('ldap authenticate %s', login)
       
   240         if not password:
       
   241             # On Windows + ADAM this would have succeeded (!!!)
       
   242             # You get Authenticated as: 'NT AUTHORITY\ANONYMOUS LOGON'.
       
   243             # we really really don't want that
       
   244             raise AuthenticationError()
       
   245         searchfilter = ['(%s=%s)' % (replace_filter(self.user_login_attr), replace_filter(login))]
       
   246         searchfilter.extend(self.base_filters)
       
   247         searchstr = '(&%s)' % ''.join(searchfilter)
       
   248         # first search the user
       
   249         try:
       
   250             user = self._search(cnx, self.user_base_dn,
       
   251                                 self.user_base_scope, searchstr)[0]
       
   252         except IndexError:
       
   253             # no such user
       
   254             raise AuthenticationError()
       
   255         # check password by establishing a (unused) connection
       
   256         try:
       
   257             self._connect(user, password)
       
   258         except ldap3.LDAPException as ex:
       
   259             # Something went wrong, most likely bad credentials
       
   260             self.info('while trying to authenticate %s: %s', user, ex)
       
   261             raise AuthenticationError()
       
   262         except Exception:
       
   263             self.error('while trying to authenticate %s', user, exc_info=True)
       
   264             raise AuthenticationError()
       
   265         eid = self.repo.system_source.extid2eid(cnx, user['dn'].encode('ascii'))
       
   266         if eid is None or eid < 0:
       
   267             # user is not known or has been moved away from this source
       
   268             raise AuthenticationError()
       
   269         return eid
       
   270 
       
   271     def _connect(self, user=None, userpwd=None):
       
   272         protocol, host, port = self.connection_info()
       
   273         self.info('connecting %s://%s:%s as %s', protocol, host, port,
       
   274                   user and user['dn'] or 'anonymous')
       
   275         server = ldap3.Server(host, port=int(port))
       
   276         conn = ldap3.Connection(server, user=user and user['dn'], client_strategy=ldap3.STRATEGY_SYNC_RESTARTABLE, auto_referrals=False)
       
   277         # Now bind with the credentials given. Let exceptions propagate out.
       
   278         if user is None:
       
   279             # XXX always use simple bind for data connection
       
   280             if not self.cnx_dn:
       
   281                 conn.bind()
       
   282             else:
       
   283                 self._authenticate(conn, {'dn': self.cnx_dn}, self.cnx_pwd)
       
   284         else:
       
   285             # user specified, we want to check user/password, no need to return
       
   286             # the connection which will be thrown out
       
   287             self._authenticate(conn, user, userpwd)
       
   288         return conn
       
   289 
       
   290     def _auth_simple(self, conn, user, userpwd):
       
   291         conn.authentication = ldap3.AUTH_SIMPLE
       
   292         conn.user = user['dn']
       
   293         conn.password = userpwd
       
   294         conn.bind()
       
   295 
       
   296     def _auth_digest_md5(self, conn, user, userpwd):
       
   297         conn.authentication = ldap3.AUTH_SASL
       
   298         conn.sasl_mechanism = 'DIGEST-MD5'
       
   299         # realm, user, password, authz-id
       
   300         conn.sasl_credentials = (None, user['dn'], userpwd, None)
       
   301         conn.bind()
       
   302 
       
   303     def _auth_gssapi(self, conn, user, userpwd):
       
   304         conn.authentication = ldap3.AUTH_SASL
       
   305         conn.sasl_mechanism = 'GSSAPI'
       
   306         conn.bind()
       
   307 
       
   308     def _search(self, cnx, base, scope,
       
   309                 searchstr='(objectClass=*)', attrs=()):
       
   310         """make an ldap query"""
       
   311         self.debug('ldap search %s %s %s %s %s', self.uri, base, scope,
       
   312                    searchstr, list(attrs))
       
   313         if self._conn is None:
       
   314             self._conn = self._connect()
       
   315         ldapcnx = self._conn
       
   316         if not ldapcnx.search(base, searchstr, search_scope=scope, attributes=attrs):
       
   317             return []
       
   318         result = []
       
   319         for rec in ldapcnx.response:
       
   320             if rec['type'] != 'searchResEntry':
       
   321                 continue
       
   322             items = rec['attributes'].items()
       
   323             itemdict = self._process_ldap_item(rec['dn'], items)
       
   324             result.append(itemdict)
       
   325         self.debug('ldap built results %s', len(result))
       
   326         return result
       
   327 
       
   328     def _process_ldap_item(self, dn, iterator):
       
   329         """Turn an ldap received item into a proper dict."""
       
   330         itemdict = {'dn': dn}
       
   331         for key, value in iterator:
       
   332             if self.user_attrs.get(key) == 'upassword': # XXx better password detection
       
   333                 value = value[0].encode('utf-8')
       
   334                 # we only support ldap_salted_sha1 for ldap sources, see: server/utils.py
       
   335                 if not value.startswith(b'{SSHA}'):
       
   336                     value = utils.crypt_password(value)
       
   337                 itemdict[key] = Binary(value)
       
   338             elif self.user_attrs.get(key) == 'modification_date':
       
   339                 itemdict[key] = datetime.strptime(value[0], '%Y%m%d%H%M%SZ')
       
   340             else:
       
   341                 if PY2 and value and isinstance(value[0], str):
       
   342                     value = [unicode(val, 'utf-8', 'replace') for val in value]
       
   343                 if len(value) == 1:
       
   344                     itemdict[key] = value = value[0]
       
   345                 else:
       
   346                     itemdict[key] = value
       
   347         # we expect memberUid to be a list of user ids, make sure of it
       
   348         member = self.group_rev_attrs['member']
       
   349         if isinstance(itemdict.get(member), string_types):
       
   350             itemdict[member] = [itemdict[member]]
       
   351         return itemdict
       
   352 
       
   353     def _process_no_such_object(self, cnx, dn):
       
   354         """Some search return NO_SUCH_OBJECT error, handle this (usually because
       
   355         an object whose dn is no more existent in ldap as been encountered).
       
   356 
       
   357         Do nothing by default, let sub-classes handle that.
       
   358         """