ud-generate: deal with users without loginShell
[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(r'^(.* )?(\d+) (\d+) (\d+) ?(.+)$')
96 SSH2AuthSplit = re.compile(r'^(.* )?ssh-(dss|rsa|ecdsa-sha2-nistp(?:256|384|521)|ed25519) ([a-zA-Z0-9=/+]+) ?(.+)$')
97 AddressSplit = re.compile(r"(.*).*<([^@]*)@([^>]*)>")
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.initialize('ldap://%s' % 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 Exception("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(r'[-+]?(\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]))