Only allow 3 password attempts
[mspang/pyceo.git] / ceo / members.py
1 """
2 CSC Member Management
3
4 This module contains functions for registering new members, registering
5 members for terms, searching for members, and other member-related
6 functions.
7
8 Transactions are used in each method that modifies the database. 
9 Future changes to the members database that need to be atomic
10 must also be moved into this module.
11 """
12 import os, re, subprocess, ldap
13 from ceo import conf, ldapi
14 from ceo.excep import InvalidArgument
15
16
17 ### Configuration ###
18
19 CONFIG_FILE = '/etc/csc/accounts.cf'
20
21 cfg = {}
22
23 def configure():
24     """Load Members Configuration"""
25
26     string_fields = [ 'username_regex', 'shells_file', 'server_url',
27             'users_base', 'groups_base', 'sasl_mech', 'sasl_realm',
28             'admin_bind_keytab', 'admin_bind_userid', 'realm',
29             'admin_principal', 'admin_keytab' ]
30     numeric_fields = [ 'min_password_length' ]
31
32     # read configuration file
33     cfg_tmp = conf.read(CONFIG_FILE)
34
35     # verify configuration
36     conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp)
37     conf.check_integer_fields(CONFIG_FILE, numeric_fields, cfg_tmp)
38
39     # update the current configuration with the loaded values
40     cfg.update(cfg_tmp)
41
42
43
44 ### Exceptions ###
45
46 class MemberException(Exception):
47     """Base exception class for member-related errors."""
48     def __init__(self, ex):
49         Exception.__init__(self)
50         self.ex = ex
51     def __str__(self):
52         return str(self.ex)
53
54 class InvalidTerm(MemberException):
55     """Exception class for malformed terms."""
56     def __init__(self, term):
57         MemberException.__init__(self)
58         self.term = term
59     def __str__(self):
60         return "Term is invalid: %s" % self.term
61
62 class NoSuchMember(MemberException):
63     """Exception class for nonexistent members."""
64     def __init__(self, memberid):
65         MemberException.__init__(self)
66         self.memberid = memberid
67     def __str__(self):
68         return "Member not found: %d" % self.memberid
69
70 class ChildFailed(MemberException):
71     def __init__(self, program, status, output):
72         MemberException.__init__(self)
73         self.program, self.status, self.output = program, status, output
74     def __str__(self):
75         msg = '%s failed with status %d' % (self.program, self.status)
76         if self.output:
77             msg += ': %s' % self.output
78         return msg
79
80
81 ### Connection Management ###
82
83 # global directory connection
84 ld = None
85
86 def connect(auth_callback):
87     """Connect to LDAP."""
88
89     configure()
90
91     global ld
92     password = None
93     tries = 0
94     while ld is None:
95         try:
96             ld = ldapi.connect_sasl(cfg['server_url'], cfg['sasl_mech'],
97                 cfg['sasl_realm'], password)
98         except ldap.LOCAL_ERROR, e:
99             tries += 1
100             if tries > 3:
101                 raise e
102             password = auth_callback.callback(e)
103             if password == None:
104                 raise e
105
106
107 def disconnect():
108     """Disconnect from LDAP."""
109
110     global ld
111     ld.unbind_s()
112     ld = None
113
114
115 def connected():
116     """Determine whether the connection has been established."""
117
118     return ld and ld.connected()
119
120
121
122 ### Members ###
123
124 def create_member(username, password, name, program):
125     """
126     Creates a UNIX user account with options tailored to CSC members.
127
128     Parameters:
129         username - the desired UNIX username
130         password - the desired UNIX password
131         name     - the member's real name
132         program  - the member's program of study
133
134     Exceptions:
135         InvalidArgument - on bad account attributes provided
136
137     Returns: the uid number of the new account
138
139     See: create()
140     """
141
142     # check username format
143     if not username or not re.match(cfg['username_regex'], username):
144         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
145
146     # check password length
147     if not password or len(password) < cfg['min_password_length']:
148         raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
149
150     try:
151         args = [ "/usr/bin/addmember", "--stdin", username, name, program ]
152         addmember = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
153         out, err = addmember.communicate(password)
154         status = addmember.wait()
155     except OSError, e:
156         raise MemberException(e)
157
158     if status:
159         raise ChildFailed("addmember", status, out+err)
160
161
162 def get(userid):
163     """
164     Look up attributes of a member by userid.
165
166     Returns: a dictionary of attributes
167
168     Example: get('mspang') -> {
169                  'cn': [ 'Michael Spang' ],
170                  'program': [ 'Computer Science' ],
171                  ...
172              }
173     """
174
175     return ldapi.lookup(ld, 'uid', userid, cfg['users_base'])
176
177
178 def list_term(term):
179     """
180     Build a list of members in a term.
181
182     Parameters:
183         term - the term to match members against
184
185     Returns: a list of members
186
187     Example: list_term('f2006'): -> {
188                  'mspang': { 'cn': 'Michael Spang', ... },
189                  'ctdalek': { 'cn': 'Calum T. Dalek', ... },
190                  ...
191              }
192     """
193
194     members = ldapi.search(ld, cfg['users_base'],
195             '(&(objectClass=member)(term=%s))', [ term ])
196
197     return dict([(member[1]['uid'][0], member[1]) for member in members])
198
199
200 def list_name(name):
201     """
202     Build a list of members with matching names.
203
204     Parameters:
205         name - the name to match members against
206
207     Returns: a list of member dictionaries
208
209     Example: list_name('Spang'): -> {
210                  'mspang': { 'cn': 'Michael Spang', ... },
211                  ...
212              ]
213     """
214
215     members = ldapi.search(ld, cfg['users_base'],
216             '(&(objectClass=member)(cn~=%s))', [ name ])
217
218     return dict([(member[1]['uid'][0], member[1]) for member in members])
219
220
221 def list_group(group):
222     """
223     Build a list of members in a group.
224
225     Parameters:
226         group - the group to match members against
227
228     Returns: a list of member dictionaries
229
230     Example: list_name('syscom'): -> {
231                  'mspang': { 'cn': 'Michael Spang', ... },
232                  ...
233              ]
234     """
235
236     members = group_members(group)
237     ret = {}
238     if members:
239         for member in members:
240             info = get(member)
241             if info:
242                 ret[member] = info
243     return ret
244
245
246 def list_positions():
247     """
248     Build a list of positions
249
250     Returns: a list of positions and who holds them
251
252     Example: list_positions(): -> {
253                  'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
254                  ...
255              ]
256     """
257
258     members = ld.search_s(cfg['users_base'], ldap.SCOPE_SUBTREE, '(position=*)')
259     positions = {}
260     for (_, member) in members:
261         for position in member['position']:
262             if not position in positions:
263                 positions[position] = {}
264             positions[position][member['uid'][0]] = member
265     return positions
266
267
268 def set_position(position, members):
269     """
270     Sets a position
271
272     Parameters:
273         position - the position to set
274         members - an array of members that hold the position
275
276     Example: set_position('president', ['dtbartle'])
277     """
278
279     res = ld.search_s(cfg['users_base'], ldap.SCOPE_SUBTREE,
280         '(&(objectClass=member)(position=%s))' % ldapi.escape(position))
281     old = set([ member['uid'][0] for (_, member) in res ])
282     new = set(members)
283     mods = {
284         'del': set(old) - set(new),
285         'add': set(new) - set(old),
286     }
287     if len(mods['del']) == 0 and len(mods['add']) == 0:
288         return
289
290     for action in ['del', 'add']:
291         for userid in mods[action]:
292             dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['users_base'])
293             entry1 = {'position' : [position]}
294             entry2 = {} #{'position' : []}
295             entry = ()
296             if action == 'del':
297                 entry = (entry1, entry2)
298             elif action == 'add':
299                 entry = (entry2, entry1)
300             mlist = ldapi.make_modlist(entry[0], entry[1])
301             ld.modify_s(dn, mlist)
302
303
304 def change_group_member(action, group, userid):
305     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['users_base'])
306     group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['groups_base'])
307     entry1 = {'uniqueMember' : []}
308     entry2 = {'uniqueMember' : [user_dn]}
309     entry = []
310     if action == 'add' or action == 'insert':
311         entry = (entry1, entry2)
312     elif action == 'remove' or action == 'delete':
313         entry = (entry2, entry1)
314     else:
315         raise InvalidArgument("action", action, "invalid action")
316     mlist = ldapi.make_modlist(entry[0], entry[1])
317     ld.modify_s(group_dn, mlist)
318
319
320
321 ### Shells ###
322
323 def get_shell(userid):
324     member = ldapi.lookup(ld, 'uid', userid, cfg['users_base'])
325     if not member:
326         raise NoSuchMember(userid)
327     if 'loginShell' not in member:
328         return
329     return member['loginShell'][0]
330
331
332 def get_shells():
333     return [ sh for sh in open(cfg['shells_file']).read().split("\n")
334                 if sh
335                 and sh[0] == '/'
336                 and not '#' in sh
337                 and os.access(sh, os.X_OK) ]
338
339
340 def set_shell(userid, shell):
341     if not shell in get_shells():
342         raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
343     ldapi.modify(ld, 'uid', userid, cfg['users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ])
344
345
346
347 ### Clubs ###
348
349 def create_club(username, name):
350     """
351     Creates a UNIX user account with options tailored to CSC-hosted clubs.
352     
353     Parameters:
354         username - the desired UNIX username
355         name     - the club name
356
357     Exceptions:
358         InvalidArgument - on bad account attributes provided
359
360     Returns: the uid number of the new account
361
362     See: create()
363     """
364
365     # check username format
366     if not username or not re.match(cfg['username_regex'], username):
367         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
368     
369     try:
370         args = [ "/usr/bin/addclub", username, name ]
371         addclub = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
372         out, err = addclub.communicate()
373         status = addclub.wait()
374     except OSError, e:
375         raise MemberException(e)
376
377     if status:
378         raise ChildFailed("addclub", status, out+err)
379
380
381
382 ### Terms ###
383
384 def register(userid, term_list):
385     """
386     Registers a member for one or more terms.
387
388     Parameters:
389         userid  - the member's username
390         term_list - the term to register for, or a list of terms
391
392     Exceptions:
393         InvalidTerm - if a term is malformed
394
395     Example: register(3349, "w2007")
396
397     Example: register(3349, ["w2007", "s2007"])
398     """
399
400     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['users_base'])
401
402     if type(term_list) in (str, unicode):
403         term_list = [ term_list ]
404
405     ldap_member = get(userid)
406     if ldap_member and 'term' not in ldap_member:
407         ldap_member['term'] = []
408
409     if not ldap_member:
410         raise NoSuchMember(userid)
411
412     new_member = ldap_member.copy()
413     new_member['term'] = new_member['term'][:]
414
415     for term in term_list:
416
417         # check term syntax
418         if not re.match('^[wsf][0-9]{4}$', term):
419             raise InvalidTerm(term)
420
421         # add the term to the entry
422         if not term in ldap_member['term']:
423             new_member['term'].append(term)
424
425     mlist = ldapi.make_modlist(ldap_member, new_member)
426     ld.modify_s(user_dn, mlist)
427
428
429 def registered(userid, term):
430     """
431     Determines whether a member is registered
432     for a term.
433
434     Parameters:
435         userid   - the member's username
436         term     - the term to check
437
438     Returns: whether the member is registered
439
440     Example: registered("mspang", "f2006") -> True
441     """
442
443     member = get(userid)
444     return 'term' in member and term in member['term']
445
446
447 def group_members(group):
448
449     """
450     Returns a list of group members
451     """
452
453     group = ldapi.lookup(ld, 'cn', group, cfg['groups_base'])
454
455     if group:
456         if 'uniqueMember' in group:
457             r = re.compile('^uid=([^,]*)')
458             return map(lambda x: r.match(x).group(1), group['uniqueMember'])
459         elif 'memberUid' in group:
460             return group['memberUid']
461         else:
462             return []
463     else:
464         return []