UDLdap.py: more useful exception if our array assumptions are violated
[mirror/userdir-ldap.git] / userdir_ldap.py
1 #   Copyright (c) 1999-2000  Jason Gunthorpe <jgg@debian.org>
2 #   Copyright (c) 2001-2003  Ryan Murray <rmurray@debian.org>
3 #   Copyright (c) 2004-2005  Joey Schulze <joey@infodrom.org>
4 #   Copyright (c) 2008 Peter Palfrader <peter@palfrader.org>
5 #   Copyright (c) 2008 Thomas Viehmann <tv@beamnet.de>
6 #
7 #   This program is free software; you can redistribute it and/or modify
8 #   it under the terms of the GNU General Public License as published by
9 #   the Free Software Foundation; either version 2 of the License, or
10 #   (at your option) any later version.
11 #
12 #   This program is distributed in the hope that it will be useful,
13 #   but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #   GNU General Public License for more details.
16 #
17 #   You should have received a copy of the GNU General Public License
18 #   along with this program; if not, write to the Free Software
19 #   Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20
21 # Some routines and configuration that are used by the ldap progams
22 import crypt
23 import getpass
24 import hashlib
25 import hmac
26 import imp
27 import ldap
28 import os
29 import pwd
30 import re
31 import rfc822
32 import sys
33 import termios
34
35 import userdir_gpg
36
37 try:
38    File = open("/etc/userdir-ldap/userdir-ldap.conf")
39 except IOError:
40    File = open("userdir-ldap.conf")
41 ConfModule = imp.load_source("userdir_config", "/etc/userdir-ldap.conf", File)
42 File.close()
43
44 # Cheap hack
45 BaseDn = ConfModule.basedn
46 HostBaseDn = ConfModule.hostbasedn
47 LDAPServer = ConfModule.ldaphost
48 EmailAppend = ConfModule.emailappend
49 AdminUser = ConfModule.adminuser
50 GenerateDir = ConfModule.generatedir
51 AllowedGroupsPreload = ConfModule.allowedgroupspreload
52 HomePrefix = ConfModule.homeprefix
53 TemplatesDir = ConfModule.templatesdir
54 PassDir = ConfModule.passdir
55 Ech_ErrorLog = ConfModule.ech_errorlog
56 Ech_MainLog = ConfModule.ech_mainlog
57 HostDomain = getattr(ConfModule, "hostdomain", EmailAppend)
58
59 try:
60    UseSSL = ConfModule.usessl
61 except AttributeError:
62    UseSSL = False
63
64 try:
65    BaseBaseDn = ConfModule.basebasedn
66 except AttributeError:
67    BaseBaseDn = BaseDn
68
69 try:
70    IgnoreUsersForUIDNumberGen = ConfModule.ignoreusersforuidnumbergen
71 except AttributeError:
72    IgnoreUsersForUIDNumberGen = ['nobody']
73
74
75 # Break up the keyring list
76 userdir_gpg.SetKeyrings(ConfModule.keyrings.split(":"))
77
78 # This is a list of common last-name prefixes
79 LastNamesPre = {"van": None, "von": None, "le": None, "de": None, "di": None}
80
81 # This is a list of common groups on Debian hosts
82 DebianGroups = {
83     "Debian": 800,
84     "guest": 60000,
85     "nogroup": 65534,
86 }
87
88 # ObjectClasses for different object types
89 UserObjectClasses = ("top", "inetOrgPerson", "debianAccount", "shadowAccount", "debianDeveloper")
90 RoleObjectClasses = ("top", "debianAccount", "shadowAccount", "debianRoleAccount")
91 GroupObjectClasses = ("top", "debianGroup")
92
93 # SSH Key splitting. The result is:
94 # (options,size,modulous,exponent,comment)
95 SSHAuthSplit = re.compile('^(.* )?(\d+) (\d+) (\d+) ?(.+)$')
96 SSH2AuthSplit = re.compile('^(.* )?ssh-(dss|rsa|ecdsa-sha2-nistp(?:256|384|521)|ed25519) ([a-zA-Z0-9=/+]+) ?(.+)$')
97 AddressSplit = re.compile("(.*).*<([^@]*)@([^>]*)>")
98
99
100 # Safely get an attribute from a tuple representing a dn and an attribute
101 # list. It returns the first attribute if there are multi.
102 def GetAttr(DnRecord, Attribute, Default=""):
103    try:
104       return DnRecord[1][Attribute][0]
105    except IndexError:
106       return Default
107    except KeyError:
108       return Default
109    return Default
110
111
112 # Return a printable email address from the attributes.
113 def EmailAddress(DnRecord):
114    cn = GetAttr(DnRecord, "cn")
115    sn = GetAttr(DnRecord, "sn")
116    uid = GetAttr(DnRecord, "uid")
117    if cn == "" and sn == "":
118       return "<" + uid + "@" + EmailAppend + ">"
119    return cn + " " + sn + " <" + uid + "@" + EmailAppend + ">"
120
121
122 # Show a dump like ldapsearch
123 def PrettyShow(DnRecord):
124    Result = ""
125    List = DnRecord[1].keys()
126    List.sort()
127    for x in List:
128       Rec = DnRecord[1][x]
129       for i in Rec:
130          Result = Result + "%s: %s\n" % (x, i)
131    return Result[:-1]
132
133
134 def connectLDAP(server=None):
135    if server is None:
136       global LDAPServer
137       server = LDAPServer
138    lc = ldap.open(server)
139    global UseSSL
140    if UseSSL:
141       lc.start_tls_s()
142    return lc
143
144
145 def passwdAccessLDAP(BaseDn, AdminUser):
146    """
147    Ask for the AdminUser's password and connect to the LDAP server.
148    Returns the connection handle.
149    """
150    print "Accessing LDAP directory as '" + AdminUser + "'"
151    while (1):
152       if 'LDAP_PASSWORD' in os.environ:
153           Password = os.environ['LDAP_PASSWORD']
154       else:
155           Password = getpass.getpass(AdminUser + "'s password: ")
156
157       if len(Password) == 0:
158          sys.exit(0)
159
160       lc = connectLDAP()
161       UserDn = "uid=" + AdminUser + "," + BaseDn
162
163       # Connect to the ldap server
164       try:
165          lc.simple_bind_s(UserDn, Password)
166       except ldap.INVALID_CREDENTIALS:
167          if 'LDAP_PASSWORD' in os.environ:
168              print "password in environment does not work"
169              del os.environ['LDAP_PASSWORD']
170          continue
171       break
172    return lc
173
174
175 # Split up a name into multiple components. This tries to best guess how
176 # to split up a name
177 def NameSplit(Name):
178    Words = re.split(" ", Name.strip())
179
180    # Insert an empty middle name
181    if len(Words) == 2:
182       Words.insert(1, "")
183    if len(Words) < 2:
184       Words.append("")
185
186    # Put a dot after any 1 letter words, must be an initial
187    for x in range(0, len(Words)):
188       if len(Words[x]) == 1:
189          Words[x] = Words[x] + '.'
190
191    # If a word starts with a -, ( or [ we assume it marks the start of some
192    # Non-name information and remove the remainder of the string
193    for x in range(0, len(Words)):
194       if len(Words[x]) != 0 and (Words[x][0] == '-' or
195                                  Words[x][0] == '(' or Words[x][0] == '['):
196          Words = Words[0:x]
197          break
198
199    # Merge any of the middle initials
200    while len(Words) > 2 and len(Words[2]) == 2 and Words[2][1] == '.':
201       Words[1] = Words[1] + Words[2]
202       del Words[2]
203
204    while len(Words) < 2:
205       Words.append('')
206
207    # Merge any of the last name prefixes into one big last name
208    while Words[-2].lower() in LastNamesPre:
209       Words[-1] = Words[-2] + " " + Words[-1]
210       del Words[-2]
211
212    # Fix up a missing middle name after lastname globbing
213    if len(Words) == 2:
214       Words.insert(1, "")
215
216    # If the name is multi-word then we glob them all into the last name and
217    # do not worry about a middle name
218    if (len(Words) > 3):
219       Words[2] = " ".join(Words[1:])
220       Words[1] = ""
221
222    return (Words[0].strip(), Words[1].strip(), Words[2].strip())
223
224
225 # Compute a random password using /dev/urandom
226 def GenPass():
227    # Generate a 10 character random string
228    SaltVals = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/."
229    Rand = open("/dev/urandom")
230    Password = ""
231    for i in range(0, 15):
232       Password = Password + SaltVals[ord(Rand.read(1)[0]) % len(SaltVals)]
233    return Password
234
235
236 # Compute the MD5 crypted version of the given password
237 def HashPass(Password):
238    # Hash it telling glibc to use the MD5 algorithm - if you dont have
239    # glibc then just change Salt = "$1$" to Salt = "";
240    SaltVals = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/."
241    Salt = "$1$"
242    Rand = open("/dev/urandom")
243    for x in range(0, 10):
244       Salt = Salt + SaltVals[ord(Rand.read(1)[0]) % len(SaltVals)]
245    Pass = crypt.crypt(Password, Salt)
246    if len(Pass) < 14:
247       raise "Password Error", "MD5 password hashing failed, not changing the password!"
248    return Pass
249
250
251 # Sync with the server, we count the number of async requests that are pending
252 # and make sure result has been called that number of times
253 def FlushOutstanding(l, Outstanding, Fast=0):
254    # Sync with the remote end
255    if Fast == 0:
256       print "Waiting for {} requests:".format(Outstanding),
257    while (Outstanding > 0):
258       try:
259          if Fast == 0 or Outstanding > 50:
260             sys.stdout.write(".",)
261             sys.stdout.flush()
262             if (l.result(ldap.RES_ANY, 1) != (None, None)):
263                Outstanding = Outstanding - 1
264          else:
265             if (l.result(ldap.RES_ANY, 1, 0) != (None, None)):
266                Outstanding = Outstanding - 1
267             else:
268                break
269       except ldap.TYPE_OR_VALUE_EXISTS:
270          Outstanding -= 1
271       except ldap.NO_SUCH_ATTRIBUTE:
272          Outstanding -= 1
273       except ldap.NO_SUCH_OBJECT:
274          Outstanding -= 1
275    if Fast == 0:
276       print
277    return Outstanding
278
279
280 # Convert a lat/long attribute into Decimal degrees
281 def DecDegree(Posn, Anon=0):
282   Parts = re.match('[-+]?(\d*)\\.?(\d*)', Posn).groups()
283   Val = float(Posn)
284
285   if (abs(Val) >= 1806060.0):
286       raise ValueError("Too Big")
287
288   # Val is in DGMS
289   if abs(Val) >= 18060.0 or len(Parts[0]) > 5:
290       Val = Val / 100.0
291       Secs = Val - long(Val)
292       Val = long(Val) / 100.0
293       Min = Val - long(Val)
294       Val = long(Val) + (Min * 100.0 + Secs * 100.0 / 60.0) / 60.0
295
296   # Val is in DGM
297   elif abs(Val) >= 180 or len(Parts[0]) > 3:
298      Val = Val / 100.0
299      Min = Val - long(Val)
300      Val = long(Val) + Min * 100.0 / 60.0
301
302   if Anon != 0:
303       Str = "%3.2f" % (Val)
304   else:
305       Str = str(Val)
306   if Val >= 0:
307      return "+" + Str
308   return Str
309
310
311 def FormatSSH2Auth(Str):
312    Match = SSH2AuthSplit.match(Str)
313    if Match is None:
314       return "<unknown format>"
315    G = Match.groups()
316
317    if G[0] is None:
318       return "ssh-%s %s..%s %s" % (G[1], G[2][:8], G[2][-8:], G[3])
319    return "%s ssh-%s %s..%s %s" % (G[0], G[1], G[2][:8], G[2][-8:], G[3])
320
321
322 def FormatSSHAuth(Str):
323    Match = SSHAuthSplit.match(Str)
324    if Match is None:
325       return FormatSSH2Auth(Str)
326    G = Match.groups()
327
328    # No options
329    if G[0] is None:
330       return "%s %s %s..%s %s" % (G[1], G[2], G[3][:8], G[3][-8:], G[4])
331    return "%s %s %s %s..%s %s" % (G[0], G[1], G[2], G[3][:8], G[3][-8:], G[4])
332
333
334 def FormatPGPKey(Str):
335    Res = ""
336
337    # PGP 2.x Print
338    if (len(Str) == 32):
339       indent = 0
340       while (indent < len(Str)):
341          if indent == 32 / 2:
342             Res = "%s %s%s " % (Res, Str[indent], Str[indent + 1])
343          else:
344             Res = "%s%s%s " % (Res, Str[indent], Str[indent + 1])
345          indent += 2
346    elif (len(Str) == 40):
347       # OpenPGP Print
348       indent = 0
349       while (indent < len(Str)):
350          if indent == 40 / 2:
351             Res = "%s %s%s%s%s " % (Res, Str[indent], Str[indent + 1], Str[indent + 2], Str[indent + 3])
352          else:
353             Res = "%s%s%s%s%s " % (Res, Str[indent], Str[indent + 1], Str[indent + 2], Str[indent + 3])
354          indent += 4
355    else:
356       Res = Str
357    return Res.strip()
358
359
360 # Take an email address and split it into 3 parts, (Name,UID,Domain)
361 def SplitEmail(Addr):
362    # Is not an email address at all
363    if Addr.find('@') == -1:
364       return (Addr, "", "")
365
366    Res1 = rfc822.AddrlistClass(Addr).getaddress()
367    if len(Res1) != 1:
368       return ("", "", Addr)
369    Res1 = Res1[0]
370    if Res1[1] is None:
371       return (Res1[0], "", "")
372
373    # If there is no @ then the address was not parsed well. Try the alternate
374    # Parsing scheme. This is particularly important when scanning PGP keys.
375    Res2 = Res1[1].split("@")
376    if len(Res2) != 2:
377       Match = AddressSplit.match(Addr)
378       if Match is None:
379          return ("", "", Addr)
380       return Match.groups()
381
382    return (Res1[0], Res2[0], Res2[1])
383
384
385 # Convert the PGP name string to a uid value. The return is a tuple of
386 # (uid,[message strings]). UnknownMpa is a hash from email to uid that
387 # overrides normal searching.
388 def GetUID(l, Name, UnknownMap={}):
389    # Crack up the email address into a best guess first/middle/last name
390    (cn, mn, sn) = NameSplit(re.sub('["]', '', Name[0]))
391
392    # Brackets anger the ldap searcher
393    cn = re.sub('[(")]', '?', cn)
394    sn = re.sub('[(")]', '?', sn)
395
396    # First check the unknown map for the email address
397    if Name[1] + '@' + Name[2] in UnknownMap:
398       Stat = "unknown map hit for " + str(Name)
399       return (UnknownMap[Name[1] + '@' + Name[2]], [Stat])
400
401    # Then the cruft component (ie there was no email address to match)
402    if Name[2] in UnknownMap:
403       Stat = "unknown map hit for" + str(Name)
404       return (UnknownMap[Name[2]], [Stat])
405
406    # Then the name component (another ie there was no email address to match)
407    if Name[0] in UnknownMap:
408       Stat = "unknown map hit for" + str(Name)
409       return (UnknownMap[Name[0]], [Stat])
410
411    # Search for a possible first/last name hit
412    try:
413       Attrs = l.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "(&(cn=%s)(sn=%s))" % (cn, sn), ["uid"])
414    except ldap.FILTER_ERROR:
415       Stat = "Filter failure: (&(cn=%s)(sn=%s))" % (cn, sn)
416       return (None, [Stat])
417
418    # Try matching on the email address
419    if len(Attrs) != 1:
420       try:
421          Attrs = l.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "emailforward=%s" % (Name[2]), ["uid"])
422       except ldap.FILTER_ERROR:
423           pass
424
425    # Hmm, more than one/no return
426    if len(Attrs) != 1:
427       # Key claims a local address
428       if Name[2] == EmailAppend:
429
430          # Pull out the record for the claimed user
431          Attrs = l.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "(uid=%s)" % (Name[1]), ["uid", "sn", "cn"])
432
433          # We require the UID surname to be someplace in the key name, this
434          # deals with special purpose keys like 'James Troup (Alternate Debian key)'
435          # Some people put their names backwards on their key too.. check that as well
436          if len(Attrs) == 1 and \
437             (sn.lower().find(Attrs[0][1]["sn"][0].lower()) != -1 or
438             cn.lower().find(Attrs[0][1]["sn"][0].lower()) != -1):
439             Stat = EmailAppend + " hit for " + str(Name)
440             return (Name[1], [Stat])
441
442       # Attempt to give some best guess suggestions for use in editing the
443       # override file.
444       Attrs = l.search_s(BaseDn, ldap.SCOPE_ONELEVEL, "(sn~=%s)" % (sn,), ["uid", "sn", "cn"])
445
446       Stat = []
447       if len(Attrs) != 0:
448          Stat = ["None for %s" % (str(Name))]
449       for x in Attrs:
450          Stat.append("But might be: %s %s <%s@debian.org>" % (x[1]["cn"][0], x[1]["sn"][0], x[1]["uid"][0]))
451       return (None, Stat)
452    else:
453       return (Attrs[0][1]["uid"][0], None)
454
455    return (None, None)
456
457
458 def Group2GID(l, name):
459    """
460    Returns the numerical id of a common group
461    on error returns -1
462    """
463    for g in DebianGroups.keys():
464       if name == g:
465          return DebianGroups[g]
466
467    filter = "(gid=%s)" % name
468    res = l.search_s(BaseDn, ldap.SCOPE_ONELEVEL, filter, ["gidNumber"])
469    if res:
470       return int(GetAttr(res[0], "gidNumber"))
471
472    return -1
473
474
475 def make_hmac(str):
476    if 'UD_HMAC_KEY' in os.environ:
477       HmacKey = os.environ['UD_HMAC_KEY']
478    else:
479       File = open(PassDir + "/key-hmac-" + pwd.getpwuid(os.getuid())[0], "r")
480       HmacKey = File.readline().strip()
481       File.close()
482    return hmac.new(HmacKey, str, hashlib.sha1).hexdigest()
483
484
485 def make_passwd_hmac(status, purpose, uid, uuid, hosts, cryptedpass):
486    return make_hmac(':'.join([status, purpose, uid, uuid, hosts, cryptedpass]))