Clean up password prompt
[public/pyceo-broken.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, terms, remote, ceo_pb2
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', 'ldap_server_url',
27             'ldap_users_base', 'ldap_groups_base', 'ldap_sasl_mech', 'ldap_sasl_realm',
28             'expire_hook', 'mathsoc_regex', 'mathsoc_dont_count' ]
29     numeric_fields = [ 'min_password_length' ]
30
31     # read configuration file
32     cfg_tmp = conf.read(CONFIG_FILE)
33
34     # verify configuration
35     conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp)
36     conf.check_integer_fields(CONFIG_FILE, numeric_fields, cfg_tmp)
37
38     # update the current configuration with the loaded values
39     cfg.update(cfg_tmp)
40
41
42
43 ### Exceptions ###
44
45 class MemberException(Exception):
46     """Base exception class for member-related errors."""
47     def __init__(self, ex=None):
48         Exception.__init__(self)
49         self.ex = ex
50     def __str__(self):
51         return str(self.ex)
52
53 class InvalidTerm(MemberException):
54     """Exception class for malformed terms."""
55     def __init__(self, term):
56         MemberException.__init__(self)
57         self.term = term
58     def __str__(self):
59         return "Term is invalid: %s" % self.term
60
61 class NoSuchMember(MemberException):
62     """Exception class for nonexistent members."""
63     def __init__(self, memberid):
64         MemberException.__init__(self)
65         self.memberid = memberid
66     def __str__(self):
67         return "Member not found: %d" % self.memberid
68
69
70 ### Connection Management ###
71
72 # global directory connection
73 ld = None
74
75 def connect(auth_callback):
76     """Connect to LDAP."""
77
78
79     global ld
80     password = None
81     tries = 0
82     while ld is None:
83         try:
84             ld = ldapi.connect_sasl(cfg['ldap_server_url'], cfg['ldap_sasl_mech'],
85                 cfg['ldap_sasl_realm'], password)
86         except ldap.LOCAL_ERROR, e:
87             tries += 1
88             if tries > 3:
89                 raise e
90             password = auth_callback.callback(e)
91             if password == None:
92                 raise e
93
94
95 def disconnect():
96     """Disconnect from LDAP."""
97
98     global ld
99     ld.unbind_s()
100     ld = None
101
102
103 def connected():
104     """Determine whether the connection has been established."""
105
106     return ld and ld.connected()
107
108
109
110 ### Members ###
111
112 def create_member(username, password, name, program):
113     """
114     Creates a UNIX user account with options tailored to CSC members.
115
116     Parameters:
117         username - the desired UNIX username
118         password - the desired UNIX password
119         name     - the member's real name
120         program  - the member's program of study
121
122     Exceptions:
123         InvalidArgument - on bad account attributes provided
124
125     Returns: the uid number of the new account
126
127     See: create()
128     """
129
130     # check username format
131     if not username or not re.match(cfg['username_regex'], username):
132         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
133
134     # check password length
135     if not password or len(password) < cfg['min_password_length']:
136         raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
137
138     try:
139         request = ceo_pb2.AddUser()
140         request.type = ceo_pb2.AddUser.MEMBER
141         request.username = username
142         request.password = password
143         request.realname = name
144         request.program = program
145
146         out = remote.run_remote('adduser', request.SerializeToString())
147
148         response = ceo_pb2.AddUserResponse()
149         response.ParseFromString(out)
150
151         if any(message.status != 0 for message in response.messages):
152             raise MemberException('\n'.join(message.message for message in response.messages))
153
154         # # If the user was created, consider adding them to the mailing list
155         # if not status:
156         #     listadmin_cfg_file = "/path/to/the/listadmin/config/file"
157         #     mail = subprocess.Popen(["/usr/bin/listadmin", "-f", listadmin_cfg_file, "--add-member", username + "@csclub.uwaterloo.ca"])
158         #     status2 = mail.wait() # Fuck if I care about errors!
159     except remote.RemoteException, e:
160         raise MemberException(e)
161     except OSError, e:
162         raise MemberException(e)
163
164
165 def get(userid):
166     """
167     Look up attributes of a member by userid.
168
169     Returns: a dictionary of attributes
170
171     Example: get('mspang') -> {
172                  'cn': [ 'Michael Spang' ],
173                  'program': [ 'Computer Science' ],
174                  ...
175              }
176     """
177
178     return ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
179
180 def uid2dn(uid):
181     return 'uid=%s,%s' % (ldapi.escape(uid), cfg['ldap_users_base'])
182
183
184 def list_term(term):
185     """
186     Build a list of members in a term.
187
188     Parameters:
189         term - the term to match members against
190
191     Returns: a list of members
192
193     Example: list_term('f2006'): -> {
194                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
195                  'uid=ctdalek, ou=...': { 'cn': 'Calum T. Dalek', ... },
196                  ...
197              }
198     """
199
200     members = ldapi.search(ld, cfg['ldap_users_base'],
201             '(&(objectClass=member)(term=%s))', [ term ])
202     return dict([(member[0], member[1]) for member in members])
203
204
205 def list_name(name):
206     """
207     Build a list of members with matching names.
208
209     Parameters:
210         name - the name to match members against
211
212     Returns: a list of member dictionaries
213
214     Example: list_name('Spang'): -> {
215                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
216                  ...
217              ]
218     """
219
220     members = ldapi.search(ld, cfg['ldap_users_base'],
221             '(&(objectClass=member)(cn~=%s))', [ name ])
222     return dict([(member[0], member[1]) for member in members])
223
224
225 def list_group(group):
226     """
227     Build a list of members in a group.
228
229     Parameters:
230         group - the group to match members against
231
232     Returns: a list of member dictionaries
233
234     Example: list_name('syscom'): -> {
235                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
236                  ...
237              ]
238     """
239
240     members = group_members(group)
241     ret = {}
242     if members:
243         for member in members:
244             info = get(member)
245             if info:
246                 ret[uid2dn(member)] = info
247     return ret
248
249
250 def list_all():
251     """
252     Build a list of all members
253
254     Returns: a list of member dictionaries
255
256     Example: list_name('Spang'): -> {
257                  'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
258                  ...
259              ]
260     """
261
262     members = ldapi.search(ld, cfg['ldap_users_base'], '(objectClass=member)')
263     return dict([(member[0], member[1]) for member in members])
264
265
266 def list_positions():
267     """
268     Build a list of positions
269
270     Returns: a list of positions and who holds them
271
272     Example: list_positions(): -> {
273                  'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
274                  ...
275              ]
276     """
277
278     members = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE, '(position=*)')
279     positions = {}
280     for (_, member) in members:
281         for position in member['position']:
282             if not position in positions:
283                 positions[position] = {}
284             positions[position][member['uid'][0]] = member
285     return positions
286
287
288 def set_position(position, members):
289     """
290     Sets a position
291
292     Parameters:
293         position - the position to set
294         members - an array of members that hold the position
295
296     Example: set_position('president', ['dtbartle'])
297     """
298
299     res = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE,
300         '(&(objectClass=member)(position=%s))' % ldapi.escape(position))
301     old = set([ member['uid'][0] for (_, member) in res ])
302     new = set(members)
303     mods = {
304         'del': set(old) - set(new),
305         'add': set(new) - set(old),
306     }
307     if len(mods['del']) == 0 and len(mods['add']) == 0:
308         return
309
310     for action in ['del', 'add']:
311         for userid in mods[action]:
312             dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
313             entry1 = {'position' : [position]}
314             entry2 = {} #{'position' : []}
315             entry = ()
316             if action == 'del':
317                 entry = (entry1, entry2)
318             elif action == 'add':
319                 entry = (entry2, entry1)
320             mlist = ldapi.make_modlist(entry[0], entry[1])
321             ld.modify_s(dn, mlist)
322
323
324 def change_group_member(action, group, userid):
325     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
326     group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['ldap_groups_base'])
327     entry1 = {'uniqueMember' : []}
328     entry2 = {'uniqueMember' : [user_dn]}
329     entry = []
330     if action == 'add' or action == 'insert':
331         entry = (entry1, entry2)
332     elif action == 'remove' or action == 'delete':
333         entry = (entry2, entry1)
334     else:
335         raise InvalidArgument("action", action, "invalid action")
336     mlist = ldapi.make_modlist(entry[0], entry[1])
337     ld.modify_s(group_dn, mlist)
338
339
340
341 ### Shells ###
342
343 def get_shell(userid):
344     member = ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
345     if not member:
346         raise NoSuchMember(userid)
347     if 'loginShell' not in member:
348         return
349     return member['loginShell'][0]
350
351
352 def get_shells():
353     return [ sh for sh in open(cfg['shells_file']).read().split("\n")
354                 if sh
355                 and sh[0] == '/'
356                 and not '#' in sh
357                 and os.access(sh, os.X_OK) ]
358
359
360 def set_shell(userid, shell):
361     if not shell in get_shells():
362         raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
363     ldapi.modify(ld, 'uid', userid, cfg['ldap_users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ])
364
365
366
367 ### Clubs ###
368
369 def create_club(username, name):
370     """
371     Creates a UNIX user account with options tailored to CSC-hosted clubs.
372     
373     Parameters:
374         username - the desired UNIX username
375         name     - the club name
376
377     Exceptions:
378         InvalidArgument - on bad account attributes provided
379
380     Returns: the uid number of the new account
381
382     See: create()
383     """
384
385     # check username format
386     if not username or not re.match(cfg['username_regex'], username):
387         raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
388     
389     try:
390         request = ceo_pb2.AddUser()
391         request.type = ceo_pb2.AddUser.CLUB
392         request.username = username
393         request.realname = name
394         out = remote.run_remote('adduser', request.SerializeToString())
395
396         response = ceo_pb2.AddUserResponse()
397         response.ParseFromString(out)
398
399         if any(message.status != 0 for message in response.messages):
400             raise MemberException('\n'.join(message.message for message in response.messages))
401     except remote.RemoteException, e:
402         raise MemberException(e)
403     except OSError, e:
404         raise MemberException(e)
405
406
407
408 ### Terms ###
409
410 def register(userid, term_list):
411     """
412     Registers a member for one or more terms.
413
414     Parameters:
415         userid  - the member's username
416         term_list - the term to register for, or a list of terms
417
418     Exceptions:
419         InvalidTerm - if a term is malformed
420
421     Example: register(3349, "w2007")
422
423     Example: register(3349, ["w2007", "s2007"])
424     """
425
426     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
427
428     if type(term_list) in (str, unicode):
429         term_list = [ term_list ]
430
431     ldap_member = get(userid)
432     if ldap_member and 'term' not in ldap_member:
433         ldap_member['term'] = []
434
435     if not ldap_member:
436         raise NoSuchMember(userid)
437
438     new_member = ldap_member.copy()
439     new_member['term'] = new_member['term'][:]
440
441     for term in term_list:
442
443         # check term syntax
444         if not re.match('^[wsf][0-9]{4}$', term):
445             raise InvalidTerm(term)
446
447         # add the term to the entry
448         if not term in ldap_member['term']:
449             new_member['term'].append(term)
450
451     mlist = ldapi.make_modlist(ldap_member, new_member)
452     ld.modify_s(user_dn, mlist)
453
454
455 def register_nonmember(userid, term_list):
456     """Registers a non-member for one or more terms."""
457
458     user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
459
460     if type(term_list) in (str, unicode):
461         term_list = [ term_list ]
462
463     ldap_member = get(userid)
464     if not ldap_member:
465         raise NoSuchMember(userid)
466
467     if 'term' not in ldap_member:
468         ldap_member['term'] = []
469     if 'nonMemberTerm' not in ldap_member:
470         ldap_member['nonMemberTerm'] = []
471
472     new_member = ldap_member.copy()
473     new_member['nonMemberTerm'] = new_member['nonMemberTerm'][:]
474
475     for term in term_list:
476
477         # check term syntax
478         if not re.match('^[wsf][0-9]{4}$', term):
479             raise InvalidTerm(term)
480
481         # add the term to the entry
482         if not term in ldap_member['nonMemberTerm'] \
483                 and not term in ldap_member['term']:
484             new_member['nonMemberTerm'].append(term)
485
486     mlist = ldapi.make_modlist(ldap_member, new_member)
487     ld.modify_s(user_dn, mlist)
488
489
490 def registered(userid, term):
491     """
492     Determines whether a member is registered
493     for a term.
494
495     Parameters:
496         userid   - the member's username
497         term     - the term to check
498
499     Returns: whether the member is registered
500
501     Example: registered("mspang", "f2006") -> True
502     """
503
504     member = get(userid)
505     if not member is None:
506         return 'term' in member and term in member['term']
507     else:
508         return False
509
510
511 def group_members(group):
512
513     """
514     Returns a list of group members
515     """
516
517     group = ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
518
519     if group and 'uniqueMember' in group:
520         r = re.compile('^uid=([^,]*)')
521         return map(lambda x: r.match(x).group(1), group['uniqueMember'])
522     return []
523
524 def expired_accounts():
525     members = ldapi.search(ld, cfg['ldap_users_base'],
526         '(&(objectClass=member)(!(|(term=%s)(nonMemberTerm=%s))))' %
527         (terms.current(), terms.current()))
528     return dict([(member[0], member[1]) for member in members])
529
530 def send_account_expired_email(name, email):
531     args = [ cfg['expire_hook'], name, email ]
532     os.spawnv(os.P_WAIT, cfg['expire_hook'], args)