New release (version 0.2).
[public/pyceo-broken.git] / pylib / csc / backends / krb.py
1 """
2 Kerberos Backend Interface
3
4 This module is intended to be a thin wrapper around Kerberos operations.
5 Methods on the connection object correspond in a straightforward way to
6 calls to the Kerberos Master server.
7
8 A Kerberos principal is the second half of a CSC UNIX account. The principal
9 stores the user's password and and is used for all authentication on CSC
10 systems. Accounts that do not authenticate (e.g. club accounts) do not need
11 a Kerberos principal.
12
13 Unfortunately, there are no Python bindings to libkadm at this time. As a
14 temporary workaround, this module communicates with the kadmin CLI interface
15 via a pseudo-terminal and a pipe.
16 """
17 import os
18 import ipc
19
20
21 class KrbException(Exception):
22     """Exception class for all Kerberos-related errors."""
23     pass
24
25
26 class KrbConnection(object):
27     """
28     Connection to the Kerberos master server (kadmind). All Kerberos
29     principal updates are made via this class.
30
31     Exceptions: (all methods)
32         KrbException - on query/update failure
33
34     Example:
35         connection = KrbConnection()
36         connection.connect(...)
37
38         # make queries and updates, e.g.
39         connection.delete_principal("mspang")
40
41         connection.disconnect()
42     """
43
44     def __init__(self):
45         self.pid = None
46     
47
48     def connect(self, principal, keytab):
49         """
50         Establishes the connection to the Kerberos master server.
51
52         Parameters:
53             principal - the Kerberos princiapl to authenticate as
54             keytab    - keytab filename for authentication
55
56         Example: connection.connect('ceo/admin@CSCLUB.UWATERLOO.CA', '/etc/ceo.keytab')
57         """
58
59         # check keytab
60         if not os.access(keytab, os.R_OK):
61             raise KrbException("cannot access Kerberos keytab: %s" % keytab)
62         
63         # command to run
64         kadmin = '/usr/sbin/kadmin'
65         kadmin_args = ['kadmin', '-p', principal, '-kt', keytab]
66         
67         # fork the kadmin command
68         self.pid, self.kadm_out, self.kadm_in = ipc.popeni(kadmin, kadmin_args)
69         
70         # read welcome messages
71         welcome = self.read_result()
72         
73         # sanity checks on welcome messages
74         for line in welcome:
75             
76             # ignore auth message
77             if line.find("Authenticating") == 0:
78                 continue
79
80             # ignore log file message
81             elif line.find("kadmin.log") != -1:
82                 continue
83
84             # error message?
85             else:
86                 raise KrbException("unexpected kadmin output: " + welcome[0])
87     
88     
89     def disconnect(self):
90         """Close the connection to the master server."""
91         
92         if self.pid:
93             
94             # close the pipe connected to kadmin's standard input
95             self.kadm_in.close()
96             
97             # close the master pty connected to kadmin's stdout
98             try:
99                 self.kadm_out.close()
100             except OSError:
101                 pass
102
103             # wait for kadmin to terminate
104             os.waitpid(self.pid, 0)
105             self.pid = None
106
107
108     def connected(self):
109         """Determine whether the connection has been established."""
110
111         return self.pid is not None
112
113
114
115     ### Helper Methods ###
116     
117     def read_result(self):
118         """
119         Helper function to read output of kadmin until it
120         prompts for input.
121
122         Returns: a list of lines returned by kadmin
123         """
124
125         # list of lines output by kadmin
126         result = []
127         lines = []
128
129         # the kadmin prompt that signals the end output
130         # note: KADMIN_ARGS[0] must be "kadmin" or the actual prompt will differ
131         prompt = "kadmin:"
132
133         # timeout variables. the timeout will start at timeout and
134         # increase up to max_timeout when read() returns nothing (i.e., times out)
135         timeout = 0.01
136         timeout_increment = 0.10
137         timeout_maximum = 1.00
138         
139         # input loop: read from kadmin until the kadmin prompt
140         buf = ''
141         while True:
142             
143             # attempt to read any available data
144             data = self.kadm_out.read(block=False, timeout=timeout)
145             buf += data
146
147             # nothing was read
148             if data == '':
149                 
150                 # so wait longer for data next time
151                 if timeout < timeout_maximum:
152                     timeout += timeout_increment
153                     continue
154
155                 # give up after too much waiting
156                 else:
157
158                     # check kadmin status
159                     status = os.waitpid(self.pid, os.WNOHANG)
160                     if status[0] == 0:
161
162                         # kadmin still alive
163                         raise KrbException("timeout while reading response from kadmin")
164
165                     else:
166
167                         # kadmin died!
168                         raise KrbException("kadmin died while reading response:\n%s\n%s" % ("\n".join(lines), buf))
169
170             # break into lines and save all but the final
171             # line (which is incomplete) into result
172             lines = buf.split("\n")
173             buf = lines[-1]
174             lines = lines[:-1]
175             for line in lines:
176                 line = line.strip()
177                 result.append(line)
178            
179             # if the incomplete line in the buffer is the kadmin prompt,
180             # then the result is complete and may be returned
181             if buf.strip() == prompt:
182                 break
183
184         return result
185     
186     
187     def execute(self, command):
188         """
189         Helper function to execute a kadmin command.
190
191         Parameters:
192             command - command string to pass on to kadmin
193         
194         Returns: a list of lines output by the command
195         """
196         
197         # there should be no remaining output from the previous
198         # command. if there is then something is broken.
199         stale_output = self.kadm_out.read(block=False, timeout=0)
200         if stale_output != '':
201             raise KrbException("unexpected kadmin output: " + stale_output)
202         
203         # send the command to kadmin
204         self.kadm_in.write(command + "\n")
205         self.kadm_in.flush()
206         
207         # read the command output and return it
208         result = self.read_result()
209         return result
210     
211
212     
213     ### Commands ###
214     
215     def list_principals(self):
216         """
217         Retrieve a list of Kerberos principals.
218
219         Returns: a list of principals
220
221         Example: connection.list_principals() -> [
222                      "ceo/admin@CSCLUB.UWATERLOO.CA",
223                      "sysadmin/admin@CSCLUB.UWATERLOO.CA",
224                      "mspang@CSCLUB.UWATERLOO.CA",
225                      ...
226                  ]
227         """
228         
229         principals = self.execute("list_principals")
230
231         # assuming that there at least some host principals
232         if len(principals) < 1:
233             raise KrbException("no kerberos principals")
234
235         # detect error message
236         if principals[0].find("kadmin:") == 0:
237             raise KrbException("list_principals returned error: " + principals[0])
238
239         # verify principals are well-formed
240         for principal in principals:
241             if principal.find("@") == -1:
242                 raise KrbException('malformed pricipal: "' + principal + '"')
243
244         return principals
245     
246     
247     def get_principal(self, principal):
248         """
249         Retrieve principal details.
250
251         Returns: a dictionary of principal attributes
252
253         Example: connection.get_principal("ceo/admin@CSCLUB.UWATERLOO.CA") -> {
254                      "Principal": "ceo/admin@CSCLUB.UWATERLOO.CA",
255                      "Policy": "[none]",
256                      ...
257                  }
258         """
259         
260         output = self.execute('get_principal "' + principal + '"')
261         
262         # detect error message
263         if output[0].find("kadmin:") == 0:
264             raise KrbException("get_principal returned error: " + output[0])
265
266         # detect more errors
267         if output[0].find("get_principal: ") == 0:
268             
269             message = output[0][15:]
270             
271             # principal does not exist => None
272             if message.find("Principal does not exist") == 0:
273                 return None
274
275         # dictionary to store attributes
276         principal_attributes = {}
277
278         # attributes that will not be returned
279         ignore_attributes = ['Key']
280
281         # split output into a dictionary of attributes
282         for line in output:
283             key, value = line.split(":", 1)
284             value = value.strip()
285             if not key in ignore_attributes:
286                 principal_attributes[key] = value
287                 
288         return principal_attributes
289     
290     
291     def get_privs(self):
292         """
293         Retrieve privileges of the current principal.
294         
295         Returns: a list of privileges
296
297         Example: connection.get_privs() ->
298                      [ "GET", "ADD", "MODIFY", "DELETE" ]
299         """
300         
301         output = self.execute("get_privs")
302
303         # one line of output is expected
304         if len(output) > 1:
305             raise KrbException("unexpected output of get_privs: " + output[1])
306
307         # detect error message
308         if output[0].find("kadmin:") == 0:
309             raise KrbException("get_privs returned error: " + output[0])
310
311         # parse output by removing the prefix and splitting it around spaces
312         if output[0][:20] != "current privileges: ":
313             raise KrbException("malformed get_privs output: " + output[0])
314         privs = output[0][20:].split(" ")
315
316         return privs
317     
318     
319     def add_principal(self, principal, password):
320         """
321         Create a new principal.
322
323         Parameters:
324             principal - the name of the principal
325             password  - the principal's initial password
326         
327         Example: connection.add_principal("mspang@CSCLUB.UWATERLOO.CA", "opensesame")
328         """
329
330         # exec the add_principal command
331         if password.find('"') == -1:
332             self.kadm_in.write('add_principal -pw "' + password + '" "' + principal + '"\n')
333             
334         # fools at MIT didn't bother implementing escaping, so passwords
335         # that contain double quotes must be treated specially
336         else:
337             self.kadm_in.write('add_principal "' + principal + '"\n')
338             self.kadm_in.write(password + "\n" + password + "\n")
339
340         # send request and read response
341         self.kadm_in.flush()
342         output = self.read_result()
343
344         # verify output
345         created = False
346         for line in output:
347
348             # ignore NOTICE lines
349             if line.find("NOTICE:") == 0:
350                 continue
351
352             # ignore prompts
353             elif line.find("Enter password") == 0 or line.find("Re-enter password") == 0:
354                 continue
355
356             # record whether success message was encountered
357             elif line.find("Principal") == 0 and line.find("created.") != 0:
358                 created = True
359
360             # error messages
361             elif line.find("add_principal:") == 0 or line.find("kadmin:") == 0:
362                 
363                 # principal exists
364                 if line.find("already exists") != -1:
365                     raise KrbException("principal already exists")
366
367                 # misc errors
368                 else:
369                     raise KrbException(line)
370
371             # unknown output
372             else:
373                 raise KrbException("unexpected add_principal output: " + line)
374            
375         # ensure success message was received
376         if not created:
377             raise KrbException("kadmin did not acknowledge principal creation")
378     
379     
380     def delete_principal(self, principal):
381         """
382         Delete a principal.
383
384         Example: connection.delete_principal("mspang@CSCLUB.UWATERLOO.CA")
385         """
386         
387         # exec the delete_principal command and read response
388         self.kadm_in.write('delete_principal -force "' + principal + '"\n')
389         self.kadm_in.flush()
390         output = self.read_result()
391
392         # verify output
393         deleted = False
394         for line in output:
395
396             # ignore reminder
397             if line.find("Make sure that") == 0:
398                 continue
399
400             # record whether success message was encountered
401             elif line.find("Principal") == 0 and line.find("deleted.") != -1:
402                 deleted = True
403
404             # error messages
405             elif line.find("delete_principal:") == 0 or line.find("kadmin:") == 0:
406                 
407                 # principal exists
408                 if line.find("does not exist") != -1:
409                     raise KrbException("principal does not exist")
410
411                 # misc errors
412                 else:
413                     raise KrbException(line)
414
415             # unknown output
416             else:
417                 raise KrbException("unexpected delete_principal output: " + line)
418            
419         # ensure success message was received
420         if not deleted:
421             raise KrbException("did not receive principal deleted")
422         
423
424     def change_password(self, principal, password):
425         """
426         Changes a principal's password.
427
428         Example: connection.change_password("mspang@CSCLUB.UWATERLOO.CA", "opensesame")
429         """
430
431         # exec the add_principal command
432         if password.find('"') == -1:
433             self.kadm_in.write('change_password -pw "' + password + '" "' + principal + '"\n')
434         else:
435             self.kadm_in.write('change_password "' + principal + '"\n')
436             self.kadm_in.write(password + "\n" + password + "\n")
437
438         # send request and read response
439         self.kadm_in.flush()
440         output = self.read_result()
441
442         # verify output
443         changed = False
444         for line in output:
445
446             # ignore NOTICE lines
447             if line.find("NOTICE:") == 0:
448                 continue
449
450             # ignore prompts
451             elif line.find("Enter password") == 0 or line.find("Re-enter password") == 0:
452                 continue
453
454             # record whether success message was encountered
455             elif line.find("Password") == 0 and line.find("changed.") != 0:
456                 changed = True
457
458             # error messages
459             elif line.find("change_password:") == 0 or line.find("kadmin:") == 0:
460                 raise KrbException(line)
461
462             # unknown output
463             else:
464                 raise KrbException("unexpected change_password output: " + line)
465            
466         # ensure success message was received
467         if not changed:
468             raise KrbException("kadmin did not acknowledge password change")
469
470
471
472 ### Tests ###
473
474 if __name__ == '__main__':
475
476     from csc.common.test import *
477     import random
478
479     conffile = '/etc/csc/kerberos.cf'
480
481     cfg = dict([map(str.strip, a.split("=", 1)) for a in map(str.strip, open(conffile).read().split("\n")) if "=" in a ])
482     principal = cfg['admin_principal'][1:-1]
483     keytab = cfg['admin_keytab'][1:-1]
484     realm = cfg['realm'][1:-1]
485
486     # t=test p=principal e=expected
487     tpname = 'testpirate' + '@' + realm
488     tpw = str(random.randint(10**30, 10**31-1)) + 'YAR!'
489     eprivs = ['GET', 'ADD', 'MODIFY', 'DELETE']
490
491     test(KrbConnection)
492     connection = KrbConnection()
493     success()
494
495     test(connection.connect)
496     connection.connect(principal, keytab)
497     success()
498
499     try:
500         connection.delete_principal(tpname)
501     except KrbException:
502         pass
503
504     test(connection.connected)
505     assert_equal(True, connection.connected())
506     success()
507
508     test(connection.add_principal)
509     connection.add_principal(tpname, tpw)
510     success()
511
512     test(connection.list_principals)
513     pals = connection.list_principals()
514     assert_equal(True, tpname in pals)
515     success()
516
517     test(connection.get_privs)
518     privs = connection.get_privs()
519     assert_equal(eprivs, privs)
520     success()
521
522     test(connection.get_principal)
523     princ = connection.get_principal(tpname)
524     assert_equal(tpname, princ['Principal'])
525     success()
526
527     test(connection.delete_principal)
528     connection.delete_principal(tpname)
529     assert_equal(None, connection.get_principal(tpname))
530     success()
531
532     test(connection.disconnect)
533     connection.disconnect()
534     assert_equal(False, connection.connected())
535     success()
536