#!/usr/bin/env python # -*- coding: utf-8 -*- # # LC_LDAP.PY-- LightWeight CransLdap # # Copyright (C) 2010 Cr@ns # Author: Antoine Durand-Gasselin # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the Cr@ns nor the names of its contributors may # be used to endorse or promote products derived from this software # without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. from __future__ import with_statement import os, sys, ldap, re, netaddr, datetime, copy, time, random from ldap.modlist import addModlist, modifyModlist from Levenshtein import jaro sys.path.append('/usr/scripts/gestion') import config, crans_utils from attributs import attrify from ldap_locks import CransLock uri = 'ldapi:///' #'ldap://ldap.adm.crans.org/' base_dn = 'ou=data,dc=crans,dc=org' log_dn = "cn=log" # Champs à ignorer dans l'historique HIST_IGNORE_FIELDS = ["modifiersName", "entryCSN", "modifyTimestamp"] def is_actif(sanction): """Retourne True ou False suivant si la sanction fournie (chaîne venant de blacklist) est active ou non. La blacklist est de la forme "debut$fin$..." """ bl_debut, bl_fin, _ = sanction.split('$', 3) now = time.time() debut = int(bl_debut) if bl_fin == '-': fin = now + 1 else: fin = int(bl_fin) return debut < now and fin > now def ldif_to_uldif(ldif): uldif = {} for attr, vals in ldif.items(): uldif[attr] = [ unicode(val, 'utf-8') for val in vals ] return uldif def ldif_to_cldif(ldif, conn, check_ctxt = True): """Transforme un dictionnaire renvoyé par python-ldap, en un dictionnaire dont les valeurs sont des instances de Attr Lorsqu'on récupère le ldif de la base ldap, on n'a pas besoin de faire de tests... """ cldif = {} for attr, vals in ldif.items(): cldif[attr] = [ attrify(val, attr, ldif, conn, check_ctxt) for val in vals] return cldif def cldif_to_ldif(cldif): ldif = {} for attr, vals in cldif.items(): ldif[attr] = [ str(val) for val in vals ] return ldif def lc_ldap_test(): """Binding LDAP à la base de tests""" return lc_ldap(dn='cn=admin,dc=crans,dc=org', cred='75bdb64f32') class lc_ldap(ldap.ldapobject.LDAPObject): def __init__(self, dn=None, user=None, cred=None, uri=uri): """Initialise la connexion ldap, - En authentifiant avec dn et cred s'ils sont précisés - Si dn n'est pas précisé, mais que user est précisé, récupère le dn associé à l'uid user, et effectue l'authentification avec ce dn et cred - Sinon effectue une authentification anonyme """ ldap.ldapobject.LDAPObject.__init__(self, uri) if user and not re.match('[a-z_][a-z0-9_-]*', user): raise ValueError('Invalid user name: %s' % user) if user and not dn: self.simple_bind_s(base_dn) res = self.search_s('uid=%s' % user) if len(res) < 1: raise ldap.INVALID_CREDENTIALS({'desc': 'No such user: %s' % user }) elif len(res) > 1: raise ldap.INVALID_CREDENTIALS({'desc': 'Too many matches: uid=%s' % user }) else: dn = res[0][0] if dn: self.conn = self.bind_s(dn, cred) else: self.conn = self.simple_bind_s() self.bind_dn = dn self.droits = [] # Penser à stocker les droits du bind_dn :) def search(self, filterstr, mode='ro', dn= base_dn, scope= 2, sizelimit=400): res = self.search_ext_s(dn, scope, filterstr, sizelimit=sizelimit) return [ new_cransldapobject(self, r[0], mode=mode) for r in res ] def allMachines(self): """Renvoie la liste de toutes les machines, Conçue pour s'éxécuter le plus rapidement possible""" res = {} machines = [] for dn, attrs in self.search_s(base_dn, scope=2): res[dn] = attrs for dn, attrs in res.items(): if dn.startswith('mid='): m = new_cransldapobject(self, dn, ldif = attrs) parent_dn = dn.split(',', 1)[1] m._proprio = new_cransldapobject(self, parent_dn, res[parent_dn]) machines.append(m) return machines def newMachine(self, parent, realm, uldif): """Crée une nouvelle machine: realm peut être: fil, fil-v6, wifi, wifi-v6, adm, gratuit, personnel-ens, special""" #adm, serveurs, bornes, wifi, adherents, gratuit ou personnel-ens""" owner = self.search('objectClass=*', dn=parent, scope=0)[0] if realm in ["adm", "serveurs"]: uldif['objectClass'] = [u'machineCrans'] assert isinstance(owner, AssociationCrans) # XXX - Vérifier les droits elif realm == "bornes": uldif['objectClass'] = [u'borneWifi'] assert isinstance(owner, AssociationCrans) # XXX - Vérifier les droits elif realm in ["wifi", "wifi-v6"]: uldif['objectClass'] = [u'machineWifi'] assert isinstance(owner, adherent) or isinstance(owner, club) # XXX - Vérifier les droits (owner.type_connexion) elif realm in ["fil", "fil-v6", "gratuit", "personnel-ens"]: uldif['objectClass'] = [u'machineFixe'] assert isinstance(owner, adherent) or isinstance(owner, club) # XXX - Vérifier les droits else: raise ValueError("Realm inconnu: %s" % realm) if realm == 'fil': plage = xrange(256, 2047) else: plage = xrange( *(config.mid[realm])) mid = uldif.setdefault('mid', [ unicode(self._find_id('mid', plage)) ]) uldif['ipHostNumber'] = [ unicode(crans_utils.ip_of_mid(int (mid[0]))) ] return self._create_entity('mid=%s,%s' % (mid[0], parent), uldif) def newAdherent(self, uldif): """Crée un nouvel adhérent""" aid = uldif.setdefault('aid', [ unicode(self._find_id('aid')) ]) uldif['objectClass'] = [u'adherent'] return self._create_entity('aid=%s,%s' % (aid[0], base_dn), uldif) def newClub(self, uldif): """Crée un nouveau club""" raise NotImplementedError() def newFacture(self, uldif): """Crée une nouvelle facture""" raise NotImplementedError() def _create_entity(self, dn, uldif): '''Crée une nouvelle entité ldap en dn, avec attributs ldif: uniquement en unicode''' cldif = ldif_to_cldif(uldif, self) #lock = CransLock(self) for item in ['aid', 'uid', 'chbre', 'mailAlias', 'canonicalAlias', 'fid', 'cid', 'mid', 'macAddress', 'host', 'hostAlias' ]: for val in cldif.get(item, []): pass #lock.add(item, val) #uldif['historique'] = [ self._hist('Création')] ldif = cldif_to_ldif(cldif) modlist = addModlist(ldif) #with lock: # print dn, modlist # self.add_s(dn, modlist) return new_cransldapobject(self, dn, mode='w') def _find_id(self, attr, plage = xrange(1, 32000)): '''Trouve un id libre dans plage''' res = self.search_s(base_dn, 2, '%s=*' % attr, attrlist = [attr]) nonfree = [ int(r[1].get(attr)[0]) for r in res if r[1].get(attr) ] nonfree.sort() for i in plage: if nonfree and nonfree[0] <= i: while nonfree and nonfree[0] <= i: nonfree = nonfree[1:] else: break else: raise EnvironmentError('Aucun %s libre dans la plage [%d, %d]' % (attr, plage[0], i)) return i def _hist(self, msg): now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M : ') return unicode(now) + msg # ? def reconnect(self, conn=None): def new_cransldapobject(conn, dn, mode='ro', ldif = None): """Crée un objet CransLdap en utilisant la classe correspondant à l'objectClass du ldif""" classe = None if ldif: classe = globals()[ldif['objectClass'][0]] elif dn == base_dn: classe = AssociationCrans else: res = conn.search_s(dn, 0) if not res: raise ValueError ('objet inexistant: %s' % dn) _, attrs = res[0] classe = globals()[attrs['objectClass'][0]] return classe(conn, dn, mode, ldif) class CransLdapObject(object): """Classe de base des objets CransLdap""" def __init__(self, conn, dn, mode='ro', ldif = None): ''' Créée une instance d'un objet Crans (machine, adhérent, etc...) à ce dn, si ldif est précisé, n'effectue pas de recherche dans la base ldap. ''' self.mode = mode self.attrs = None # Contient un dico uldif qui doit représenter ce qui # est dans la base self._modifs = None # C'est là qu'on met les modifications if not isinstance(conn, lc_ldap): raise TypeError("conn doit être une instance de lc_ldap") self.conn = conn self.dn = dn if ldif: # Vous précisez un ldif, l'objet est 'ro' self.mode = 'ro' self.attrs = ldif elif dn != base_dn: res = conn.search_s(dn, 0) if not res: raise ValueError ('objet inexistant: %s' % dn) self.dn, self.attrs = res[0] self.attrs = ldif_to_uldif(self.attrs) if mode in ['w', 'rw']: self._modifs = copy.deepcopy(self.attrs) self.attrs = ldif_to_cldif(self.attrs, conn, check_ctxt = False) ### Vérification que `λv. str(Attr(v))` est bien une projection oldif = res[0][1] nldif = cldif_to_ldif(self.attrs) for attr, vals in oldif.items(): if nldif[attr] != vals: for v in nldif[attr]: if v in vals: vals.remove(v) nvals = [nldif[attr][v.index(v)] for v in vals ] raise EnvironmentError("λv. str(Attr(v)) n'est peut-être pas une projection:", attr, nvals, vals) # self._modifs = copy.deepcopy(self.attrs) def save(self): "Vérifie que self._modifs contient des valeurs correctes et enregistre les modifications" if self.mode not in ['w', 'rw']: raise EnvironmentError("Objet en lecture seule, réessayer en lecture/écriture") # Vérifications et Historique #histo = self._gen_hist(self._modifs) #self._modifs['historique'] += histo # On récupère la liste des modifications modlist = self.get_modlist() self.conn.modify_s(self.dn, modlist) # Vérification des modifications self.attrs = ldif_to_uldif(self.conn.search_s(self.dn, 0)[0][1]) if self.attrs != self._modifs: raise EnvironmentError("Les modifications apportées à l'objet %s n'ont pas été correctement sauvegardées\nexpected = %s, found = %s" % (self.dn, self._modifs, self.attrs)) def get_modlist(self): """Renvoie le dico des modifs""" # unicode -> utf-8 ldif = cldif_to_ldif(self._modifs) orig_ldif = cldif_to_ldif(self.attrs) return modifyModlist(orig_ldif, ldif) def get_values(self, attr): """Renvoie les valeurs d'un attribut ldap de l'objet self""" attrs = self.attrs.get(attr, []) return attrs def get_value(self, attr): """Renvoie la première valeur d'un attribut ldap de l'objet self""" return self.get_values(attr)[0] def set_ldapattr(self, attr, new_vals): """Définit les nouvelles valeurs d'un attribut""" if self.mode not in ['w', 'rw']: raise EnvironmentError("Objet en lecture seule, réessayer en lecture/écriture") if not isinstance(new_vals, list): new_vals = [new_vals] # On attrify cldif = self.attrs.copy() cldif[attr] = new_vals new_vals = [ attrify(val, attr, cldif, self.conn) for val in new_vals ] # Si ça passe, on effectue les modifications old_vals = [ str(val) for val in self.attrs.get(attr, []) ] new_vals = [ str(val) for val in new_vals ] modlist = modifyModlist({attr : old_vals}, {attr : new_vals}) self.conn.modify_s(self.dn, modlist) def mod_ldapattr(self, attr, new_val, old_val = None): """Modifie l'attribut attr ayant la valeur oldVal en newVal. Si l'attribut attr n'a qu'une seule valeur, il n'est pas nécessaire de préciser oldVal.""" new_vals = self.attrs.get(attr, [])[:] if old_val: # and oldVal in attrs: new_vals = [ val for val in new_vals if str(val) != str(old_val) ] new_vals.append(new_val) elif len(new_vals) == 1: new_vals = [ new_val ] else: raise ValueError("%s has multiple values, must specify old_val") return self.set_ldapattr(attr, new_vals) def del_ldapattr(self, attr, val): """Supprime la valeur val de l'attribut attr""" new_vals = self.attrs.get(attr, [])[:] new_vals = [ v for v in new_vals if str(v) != str(val) ] return self.set_ldapattr(attr, new_vals) def add_ldapattr(self, attr, new_val): """Rajoute la valeur val à l'attribut attr""" new_vals = self.attrs.get(attr, [])[:] new_vals.append(new_val) return self.set_ldapattr(attr, new_vals) def _gen_hist(self, modifs): # XXX - Kill it! l'historique devrait être généré par ldap """Genère l'historique des modifications apportées. Cette fonction n'est là que pour de la rétro-compatibilité, normalement les modifications sont automatiquement loggées.""" histo = [] for field in self.ofields: if modifs.get(field, []) != self.attrs.get(field, []): if modifs.get(field, []) == []: msg = u"[%s] %s -> RESET" % (field, self.attrs[field][0]) elif self.attrs.get(field, []) == []: msg = u"[%s] := %s" % (field, modifs[field][0]) else: msg = u"[%s] %s -> %s" % (field, self.attrs[field][0], modifs[field][0]) histo.append(self.conn._hist(msg)) for field in self.xfields + self.ufields: if modifs.get(field, []) != self.attrs.get(field, []): msg = u"[%s] %s -> %s" % (field, u'; '.join(self.attrs[field]), u'; '.join(modifs[field])) histo.append(self.conn._hist(msg)) for field in self.mfields: oldvals = self.attrs.get(field, []) newvals = modifs.get(field, []) olds = set(oldvals) news = set(newvals) if oldvals == newvals: continue elif olds != news: adds = ''.join([ '+' + val for val in news - olds]) diff = ''.join([ '-' + val for val in olds - news]) msg = u'[%s]%s%s' % ( field, adds, diff) elif olds == news and len(oldvals) == len(newvals) == len(olds): msg = u"[%s].shuffle()" % field else: raise ValueError("Les valeurs pour %s : %s -> %s ne semblent pas différentes" % (field, oldvals, newvals)) histo.append(self.conn._hist(msg)) return histo def search_historique(self, ign_fields=HIST_IGNORE_FIELDS): u"""Récupère l'historique l'argument optionnel ign_fields contient la liste des champs à ignorer, HIST_IGNORE_FIELDS par défaut Renvoie une liste de lignes de texte.""" res = self.conn.search_s(log_dn, 2, 'reqDN=%s' % self.dn) res.sort(key=(lambda a: a[1]['reqEnd'][0])) out = [] for cn, attrs in res: date = crans_utils.format_ldap_time(attrs['reqEnd'][0]) if attrs['reqType'][0] == 'delete': out.append("%s : [%s] Suppression" % (date, attrs['reqAuthzID'][0])) elif attrs['reqType'][0] == 'modify': fields = {} for mod in attrs['reqMod']: field, change = mod.split(':', 1) if field not in ign_fields: if field in fields: fields[field].append(change) else: fields[field] = [change] mod_list = [] for field in fields: mods = fields[field] mod_list.append("%s %s" %(field, ", ".join(mods))) if mod_list != []: out.append("%s : [%s] %s" % (date, attrs['reqAuthzID'][0], " ; ".join(mod_list))) return out def blacklist_actif(self): u"""Vérifie si l'instance courante est blacklistée. Retourne les sanctions en cours (liste). Retourne une liste vide si aucune sanction en cours. """ return self.blacklist_all()[0].keys() def blacklist_all(self): u"""Vérifie si l'instance courante est blacklistée ou a été blacklistée. Retourne les sanctions en cours sous la forme d'un couple de deux dictionnaires (l'un pour les sanctions actives, l'autre pour les inactive), chacun ayant comme clef la sanction et comme valeur une liste de couple de dates (en secondes depuis epoch) correspondant aux différentes périodes de sanctions. ex: {'upload': [(1143336210, 1143509010), ...]} """ bl_liste = self.attrs.get('blacklist', []) if isinstance(self, machine): # Blist du propriétaire bl_liste += proprio().blacklist() actifs = {}; inactifs = {} for sanction in bl_liste: f = sanction.split('$') if is_actif(sanction): actifs.setdefault(f[2], []).append((f[0], f[1])) else: inactifs.setdefault(f[2], []).append((f[0], f[1])) return (actifs, inactifs) def blacklist(self, new=None): u""" Blacklistage de la ou de toutes la machines du propriétaire * new est une liste de 4 termes : [debut_sanction, fin_sanction, sanction, commentaire] * début et fin sont le nombre de secondes depuis epoch * pour un début ou fin immédiate mettre now * pour une fin indéterminée mettre '-' Les données sont stockées dans la base sous la forme : debut$fin$sanction$commentaire Pour modifier une entrée donner un tuple de deux termes : (index dans blacklist à modifier, nouvelle liste), l'index étant celui dans la liste retournée par blacklist(). """ Blist = self.attrs.setdefault('blacklist', [])[:] if new == None: return Blist if type(new) == tuple: # Modification index = new[0] new = new[1] if new == '': Blist.pop(index) return Blist else: index = -1 if type(new) != list or len(new) != 4: raise TypeError # Verification que les dates sont OK if new[0] == 'now': debut = new[0] = int(time.time()) else: try: debut = new[0] = int(new[0]) except: raise ValueError('Date de début blacklist invalide') if new[1] == 'now': fin = new[1] = int(time.time()) elif new[1] == '-': fin = -1 else: try: fin = new[1] = int(new[1]) except: raise ValueError('Date de fin blacklist invalide') if debut == fin: raise ValueError('Dates de début et de fin identiques') elif fin != -1 and debut > fin: raise ValueError('Date de fin avant date de début') # On dépasse la fin de sanction d'1min pour être sûr qu'elle est périmée. fin = fin + 60 new_c = u'$'.join(map(str, new)) if index != -1: Blist[index] = new_c else: Blist.append(new_c) if Blist != self.attrs.get('blacklist'): self.set_ldapattr('blacklist', Blist) if not hasattr(self, "_blacklist_restart"): self._blacklist_restart = {} restart = self._blacklist_restart.setdefault(new[2], []) if debut not in restart: restart.append(debut) if fin != -1 and fin not in restart: restart.append(fin) return Blist class proprio(CransLdapObject): ufields = [ 'nom', 'chbre' ] mfields = [ 'paiement', 'info', 'blacklist', 'controle'] ofields = [] xfields = [] def __init__(self, conn, dn, mode='ro', ldif = None): super(proprio, self).__init__(conn, dn, mode, ldif) self._machines = [] def machines(self): if not self._machines: self._machines = self.conn.search('mid=*', dn = self.dn, scope = 1) for m in self._machines: m._proprio = self return self._machines class machine(CransLdapObject): ufields = ['mid', 'macAddress', 'host', 'midType'] ofields = [] mfields = ['info', 'blacklist', 'hostAlias', 'exempt', 'portTCPout', 'portTCPin', 'portUDPout', 'portUDPin'] xfields = ['ipHostNumber'] def __init__(self, conn, dn, mode='ro', ldif = None): super(machine, self).__init__(conn, dn, mode, ldif) self._proprio = None def proprio(self): parent_dn = self.dn.split(',', 1)[1] if not self._proprio: self._proprio = new_cransldapobject(self.conn, parent_dn, self.mode) return self._proprio class AssociationCrans(proprio): pass class adherent(proprio): ufields = proprio.ufields + ['aid', 'prenom', 'tel', 'mail', 'mailInvalide'] ofields = proprio.ofields + ['charteMA', 'adherentPayant', 'typeAdhesion', 'canonicalAlias', 'solde', 'contourneGreylist', 'rewriteMailHeaders', 'derniereConnexion', 'homepageAlias'] mfields = proprio.mfields + ['carteEtudiant', 'mailAlias', 'droits' ] xfields = ['etudes', 'postalAddress'] def compte(self,login = None, uidNumber=0, hash_pass = '', shell=config.login_shell): """Renvoie le nom du compte crans, s'il n'existe pas, et que uid est précisé, le crée""" if u'posixAccount' in self.attrs['objectClass']: return self.attrs['uid'][0] elif login: fn = crans_utils.strip_accents(unicode(self.attrs['prenom'][0]).capitalize()) ln = crans_utils.strip_accents(unicode(self.attrs['nom'][0]).capitalize()) login = crans_utils.strip_accents(login).lower() if jaro(ln.lower(), login) < 0.75 and jaro(fn.lower() + ' ' + ln.lower(), login) < 0.75: raise ValueError("Le login est trop différent du nom", login, self.attrs['nom'][0]) if not re.match('^[a-z][-a-z]{1,15}$', login): raise ValueError("Le login a entre 2 et 16 lettres, il peut contenir (pas au début) des - ") if crans_utils.mailexist(login): raise ValueError("Login existant ou correspondant à un alias mail.") home = u'/home/' + login if os.path.exists(home): raise ValueError('Création du compte impossible : home existant') if os.path.exists("/var/mail/" + login): raise ValueError('Création du compte impossible : /var/mail/%s existant' % login) self._modifs['homeDirectory'] = [home] self._modifs['mail'] = [login] self._modifs['uid' ] = [login] calias = crans_utils.strip_spaces(fn) + u'.' + crans_utils.strip_spaces(ln) if crans_utils.mailexist(calias): calias = login self._modifs['canonicalAlias'] = [calias] self._modifs['objectClass'] = [u'adherent', u'cransAccount', u'posixAccount', u'shadowAccount'] self._modifs['cn'] = [ fn + u' ' + ln ] self._modifs['loginShell'] = [unicode(shell)] self._modifs['userPassword'] = [unicode(hash_pass)] if uidNumber: if self.conn.search('(uidNumber=%s)' % uidNumber): raise ValueError(u'uidNumber pris') else: pool_uid = range(1001, 9999) random.shuffle(pool_uid) while len(pool_uid) > 0: uidNumber = pool_uid.pop() # On choisit un uid if not self.conn.search('(uidNumber=%s)' % uidNumber): break if not len(pool_uid): raise ValueError("Plus d'uid disponibles !") ## try: ## self.lock('uidNumber', str(uidNumber)) ## except: ## # Quelqu'un nous a piqué l'uid que l'on venait de choisir ! ## return self.compte(login, uidNumber, hash_pass, shell) self._modifs['uidNumber'] = [unicode(uidNumber)] self._modifs['gidNumber'] = [unicode(config.gid)] self._modifs['gecos'] = [self._modifs['cn'][0] + u',,,'] self.save() else: raise EnvironmentError("L'adhérent n'a pas de compte crans") class club(proprio): ufields = ['cid', 'responsable'] mfields = ['imprimeurClub'] class machineFixe(machine): pass class machineWifi(machine): ufields = machine.ufields + ['ipsec'] class machineCrans(machine): ufields = machine.ufields + ['prise'] ofields = machine.ofields + ['nombrePrises'] class borneWifi(machine): ufields = machine.ufields + ['canal', 'puissane', 'hotspot', 'prise', 'positionBorne', 'nvram'] class facture(CransLdapObject): ufields = ['fid', 'modePaiement', 'recuPaiement'] class service(CransLdapObject): pass class lock(CransLdapObject): pass MODIFIABLE_ATTRS = [ 'tel', 'chbre', 'mailAlias', 'loginShell' ]