chiark / gitweb /
Found in crybaby's working tree.
[chopwood] / backend.py
1 ### -*-python-*-
2 ###
3 ### Password backends
4 ###
5 ### (c) 2013 Mark Wooding
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of Chopwood: a password-changing service.
11 ###
12 ### Chopwood is free software; you can redistribute it and/or modify
13 ### it under the terms of the GNU Affero General Public License as
14 ### published by the Free Software Foundation; either version 3 of the
15 ### License, or (at your option) any later version.
16 ###
17 ### Chopwood is distributed in the hope that it will be useful,
18 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
19 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 ### GNU Affero General Public License for more details.
21 ###
22 ### You should have received a copy of the GNU Affero General Public
23 ### License along with Chopwood; if not, see
24 ### <http://www.gnu.org/licenses/>.
25
26 from __future__ import with_statement
27
28 from auto import HOME
29 import errno as E
30 import itertools as I
31 import os as OS; ENV = OS.environ
32
33 import config as CONF; CFG = CONF.CFG
34 import util as U
35
36 ###--------------------------------------------------------------------------
37 ### Relevant configuration.
38
39 CONF.DEFAULTS.update(
40
41   ## A directory in which we can create lockfiles.
42   LOCKDIR = OS.path.join(HOME, 'lock'))
43
44 ###--------------------------------------------------------------------------
45 ### Utilities.
46
47 def fill_in_fields(fno_user, fno_passwd, fno_map,
48                    user, passwd, args, defaults = None):
49   """
50   Return a vector of filled-in fields.
51
52   The FNO_... arguments give field numbers: FNO_USER and FNO_PASSWD give the
53   positions for the username and password fields, respectively; and FNO_MAP
54   is a sequence of (NAME, POS) pairs.  The USER and PASSWD arguments give the
55   actual user name and password values; ARGS are the remaining arguments,
56   maybe in the form `NAME=VALUE'.
57
58   If DEFAULTS is given, it is a fully-filled-in sequence of records.  The
59   user name and password are taken from the DEFAULTS if USER and PASSWD are
60   `None' on entry; they cannot be set from ARGS.
61   """
62
63   ## Prepare the result vector, and set up some data structures.
64   n = 2 + len(fno_map)
65   fmap = {}
66   rmap = map(int, xrange(n))
67   ok = True
68   if fno_user >= n or fno_passwd >= n: ok = False
69   for k, i in fno_map:
70     fmap[k] = i
71     if i in rmap: ok = False
72     else: rmap[i] = "`%s'" % k
73     if i >= n: ok = False
74   if not ok:
75     raise U.ExpectedError, \
76         (500, "Fields specified aren't contiguous")
77
78   ## Prepare the new record's fields.
79   f = [None]*n
80   if user is not None: f[fno_user] = user
81   elif defaults is not None: f[no_user] = defaults[no_user]
82   else: raise U.ExpectedError, (500, "No user name given")
83   if passwd is not None: f[fno_passwd] = passwd
84   elif defaults is not None: f[no_passwd] = defaults[no_passwd]
85   else: raise U.ExpectedError, (500, "No password given")
86
87   for a in args:
88     if '=' in a:
89       k, v = a.split('=', 1)
90       try: i = fmap[k]
91       except KeyError: raise U.ExpectedError, (400, "Unknown field `%s'" % k)
92     else:
93       for i in xrange(n):
94         if f[i] is None: break
95       else:
96         raise U.ExpectedError, (500, "All fields already populated")
97       v = a
98     if f[i] is not None:
99       raise U.ExpectedError, (400, "Field %s is already set" % rmap[i])
100     f[i] = v
101
102   ## Check that the vector of fields is properly set up.  Copy unset values
103   ## from the defaults if they're available.
104   for i in xrange(n):
105     if f[i] is not None: pass
106     elif defaults is not None: f[i] = defaults[i]
107     else: raise U.ExpectedError, (500, "Field %s is unset" % rmap[i])
108
109   ## Done.
110   return f
111
112 ###--------------------------------------------------------------------------
113 ### Protocol.
114 ###
115 ### A password backend knows how to fetch and modify records in some password
116 ### database, e.g., a flat passwd(5)-style password file, or a table in some
117 ### proper grown-up SQL database.
118 ###
119 ### A backend's `lookup' method retrieves the record for a named user from
120 ### the database, returning it in a record object, or raises `UnknownUser'.
121 ### The record object maintains `user' (the user name, as supplied to
122 ### `lookup') and `passwd' (the encrypted password, in whatever form the
123 ### underlying database uses) attributes, and possibly others.  The `passwd'
124 ### attribute (at least) may be modified by the caller.  The record object
125 ### has a `write' method, which updates the corresponding record in the
126 ### database.
127 ###
128 ### The concrete record objects defined here inherit from `BasicRecord',
129 ### which keeps track of its parent backend, and implements `write' by
130 ### calling the backend's `_update' method.  Some backends require that their
131 ### record objects implement additional private protocols.
132
133 class UnknownUser (U.ExpectedError):
134   """The named user wasn't found in the database."""
135   def __init__(me, user):
136     U.ExpectedError.__init__(me, 500, "Unknown user `%s'" % user)
137     me.user = user
138
139 class BasicRecord (object):
140   """
141   A handy base class for record classes.
142
143   Keep track of the backend in `_be', and call its `_update' method to write
144   ourselves back.
145   """
146   def __init__(me, backend):
147     me._be = backend
148   def write(me):
149     me._be._update(me)
150   def remove(me):
151     me._be._remove(me)
152
153 class TrivialRecord (BasicRecord):
154   """
155   A trivial record which simply remembers `user' and `passwd' attributes.
156
157   Additional attributes can be set on the object if this is convenient.
158   """
159   def __init__(me, user, passwd, *args, **kw):
160     super(TrivialRecord, me).__init__(*args, **kw)
161     me.user = user
162     me.passwd = passwd
163
164 ###--------------------------------------------------------------------------
165 ### Flat files.
166
167 class FlatFileRecord (BasicRecord):
168   """
169   A record from a flat-file database (like a passwd(5) file).
170
171   Such a file carries one record per line; each record is split into fields
172   by a delimiter character, specified by the DELIM constructor argument.
173
174   The FMAP argument to the constructor maps names to field index numbers.
175   The standard `user' and `passwd' fields must be included in this map if the
176   object is to implement the protocol correctly (though the `FlatFileBackend'
177   is careful to do this).
178   """
179
180   def __init__(me, line, delim, fno_user, fno_passwd, fmap, *args, **kw):
181     """
182     Initialize the record, splitting the LINE into fields separated by DELIM,
183     and setting attributes under control of FMAP.
184     """
185     super(FlatFileRecord, me).__init__(*args, **kw)
186     line = line.rstrip('\n')
187     fields = line.split(delim)
188     me._delim = delim
189     me._fno_user = fno_user
190     me._fno_passwd = fno_passwd
191     me._fmap = fmap
192     me._raw = fields
193     for k, v in fmap.iteritems():
194       setattr(me, k, fields[v])
195
196   def _format(me):
197     """
198     Format the record as a line of text.
199
200     The flat-file format is simple, but rather fragile with respect to
201     invalid characters, and often processed by substandard software, so be
202     careful not to allow bad characters into the file.
203     """
204     fields = me._raw
205     for k, v in me._fmap.iteritems():
206       val = getattr(me, k)
207       for badch, what in [(me._delim, "delimiter `%s'" % me._delim),
208                           ('\n', 'newline character'),
209                           ('\0', 'null character')]:
210         if badch in val:
211           raise U.ExpectedError, \
212                 (500, "New `%s' field contains %s" % (k, what))
213       fields[v] = val
214     return me._delim.join(fields) + '\n'
215
216   def edit(me, args):
217     """
218     Modify the record as described by the ARGS.
219
220     Fields other than the user name and password can be modified.
221     """
222     ff = fill_in_fields(
223
224 class FlatFileBackend (object):
225   """
226   Password storage in a flat passwd(5)-style file.
227
228   The FILE constructor argument names the file.  Such a file carries one
229   record per line; each record is split into fields by a delimiter character,
230   specified by the DELIM constructor argument.
231
232   The file is updated by writing a new version alongside, as `FILE.new', and
233   renaming it over the old version.  If a LOCK is provided then this is done
234   while holding a lock.  By default, an exclusive fcntl(2)-style lock is
235   taken out on `LOCKDIR/LOCK' (creating the file if necessary) during the
236   update operation, but subclasses can override the `dolocked' method to
237   provide alternative locking behaviour; the LOCK parameter is not
238   interpreted by any other methods.  Use of a lockfile is strongly
239   recommended.
240
241   The DELIM constructor argument specifies the delimiter character used when
242   splitting lines into fields.  The USER and PASSWD arguments give the field
243   numbers (starting from 0) for the user-name and hashed-password fields;
244   additional field names may be given using keyword arguments: the values of
245   these fields are exposed as attributes `f_NAME' on record objects.
246   """
247
248   def __init__(me, file, lock = None,
249                delim = ':', user = 0, passwd = 1, **fields):
250     """
251     Construct a new flat-file backend object.  See the class documentation
252     for details.
253     """
254     me._lock = lock
255     me._file = file
256     me._delim = delim
257     fmap = dict(user = user, passwd = passwd)
258     for k, v in fields.iteritems(): fmap['f_' + k] = v
259     me._fmap = fmap
260
261   def lookup(me, user):
262     """Return the record for the named USER."""
263     with open(me._file) as f:
264       for line in f:
265         rec = me._parse(line)
266         if rec.user == user:
267           return rec
268     raise UnknownUser, user
269
270   def create(me, user, passwd, args):
271     """
272     Create a new record for the USER.
273
274     The new record has the given PASSWD, and other fields are set from ARGS.
275     Those ARGS of the form `KEY=VALUE' set the appropriately named fields (as
276     set up by the constructor); other ARGS fill in unset fields, left to
277     right.
278     """
279
280     f = fill_in_fields(me._fmap['user'], me._fmap['passwd'],
281                        [(k[2:], i)
282                         for k, i in me._fmap.iteritems()
283                         if k.startswith('f_')],
284                        user, passwd, args)
285     r = FlatFileRecord(me._delim.join(f), me._delim, me._fmap, backend = me)
286     me._rewrite('create', r)
287
288   def _rewrite(me, op, rec):
289     """
290     Rewrite the file, according to OP.
291
292     The OP may be one of the following.
293
294     `create'            There must not be a record matching REC; add a new
295                         one.
296
297     `remove'            There must be a record matching REC: remove it.
298
299     `update'            There must be a record matching REC: write REC in its
300                         place.
301     """
302
303     ## The main update function.
304     def doit():
305
306       ## Make sure we preserve the file permissions, and in particular don't
307       ## allow a window during which the new file has looser permissions than
308       ## the old one.
309       st = OS.stat(me._file)
310       tmp = me._file + '.new'
311       fd = OS.open(tmp, OS.O_WRONLY | OS.O_CREAT | OS.O_EXCL, st.st_mode)
312
313       ## This is the fiddly bit.
314       lose = True
315       try:
316
317         ## Copy the old file to the new one, changing the user's record if
318         ## and when we encounter it.
319         found = False
320         with OS.fdopen(fd, 'w') as f_out:
321           with open(me._file) as f_in:
322             for line in f_in:
323               r = me._parse(line)
324               if r.user != rec.user:
325                 f_out.write(line)
326               elif op == 'create':
327                 raise U.ExpectedError, \
328                     (500, "Record for `%s' already exists" % rec.user)
329               else:
330                 found = True
331                 if op != 'remove': f_out.write(rec._format())
332           if found:
333             pass
334           elif op == 'create':
335             f_out.write(rec._format())
336           else:
337             raise U.ExpectedError, \
338                 (500, "Record for `%s' not found" % rec.user)
339
340         ## Update the permissions on the new file.  Don't try to fix the
341         ## ownership (we shouldn't be running as root) or the group (the
342         ## parent directory should have the right permissions already).
343         OS.chmod(tmp, st.st_mode)
344         OS.rename(tmp, me._file)
345         lose = False
346       except OSError, e:
347         ## I suppose that system errors are to be expected at this point.
348         raise U.ExpectedError, \
349               (500, "Failed to update `%s': %s" % (me._file, e))
350       finally:
351         ## Don't try to delete the new file if we succeeded: it might belong
352         ## to another instance of us.
353         if lose:
354           try: OS.unlink(tmp)
355           except: pass
356
357     ## If there's a lockfile, then acquire it around the meat of this
358     ## function; otherwise just do the job.
359     if me._lock is None: doit()
360     else: me.dolocked(me._lock, doit)
361
362   def dolocked(me, lock, func):
363     """
364     Call FUNC with the LOCK held.
365
366     Subclasses can override this method in order to provide alternative
367     locking functionality.
368     """
369     try: OS.mkdir(CFG.LOCKDIR)
370     except OSError, e:
371       if e.errno != E.EEXIST: raise
372     with U.lockfile(OS.path.join(CFG.LOCKDIR, lock), 5):
373       func()
374
375   def _parse(me, line):
376     """Convenience function for constructing a record."""
377     return FlatFileRecord(line, me._delim, me._fmap, backend = me)
378
379   def _update(me, rec):
380     """Update the record REC in the file."""
381     me._rewrite('update', rec)
382
383   def _remove(me, rec):
384     """Update the record REC in the file."""
385     me._rewrite('remove', rec)
386
387 CONF.export('FlatFileBackend')
388
389 ###--------------------------------------------------------------------------
390 ### SQL databases.
391
392 class DatabaseBackend (object):
393   """
394   Password storage in a SQL database table.
395
396   We assume that there's a single table mapping user names to (hashed)
397   passwords: we won't try anything complicated involving joins.
398
399   We need to know a database module MODNAME and arguments MODARGS to pass to
400   the `connect' function.  We also need to know the TABLE to search, and the
401   USER and PASSWD field names.  Additional field names can be passed to the
402   constructor: these will be read from the database and attached as
403   attributes `f_NAME' to the record returned by `lookup'.  Changes to these
404   attributes are currently not propagated back to the database.
405   """
406
407   def __init__(me, modname, modargs, table, user, passwd, *fields):
408     """
409     Create a database backend object.  See the class docstring for details.
410     """
411     me._table = table
412     me._user = user
413     me._passwd = passwd
414     me._fields = list(fields)
415
416     ## We don't connect immediately.  That would be really bad if we had lots
417     ## of database backends running at a time, because we probably only want
418     ## to use one.
419     me._db = None
420     me._modname = modname
421     me._modargs = modargs
422
423   def _connect(me):
424     """Set up the lazy connection to the database."""
425     if me._db is None:
426       me._db = U.SimpleDBConnection(me._modname, me._modargs)
427
428   def lookup(me, user):
429     """Return the record for the named USER."""
430     me._connect()
431     me._db.execute("SELECT %s FROM %s WHERE %s = $user" %
432                    (', '.join([me._passwd] + me._fields),
433                     me._table, me._user),
434                    user = user)
435     row = me._db.fetchone()
436     if row is None: raise UnknownUser, user
437     passwd = row[0]
438     rec = TrivialRecord(backend = me, user = user, passwd = passwd)
439     for f, v in zip(me._fields, row[1:]):
440       setattr(rec, 'f_' + f, v)
441     return rec
442
443   def create(me, user, passwd, args):
444     """
445     Create a new record for the named USER.
446
447     The new record has the given PASSWD, and other fields are set from ARGS.
448     Those ARGS of the form `KEY=VALUE' set the appropriately named fields (as
449     set up by the constructor); other ARGS fill in unset fields, left to
450     right, in the order given to the constructor.
451     """
452
453     tags = ['user', 'passwd'] + \
454         ['t_%d' % 0 for i in xrange(len(me._fields))]
455     f = fill_in_fields(0, 1, list(I.izip(me._fields, I.count(2))),
456                        user, passwd, args)
457     me._connect()
458     with me._db:
459       me._db.execute("INSERT INTO %s (%s) VALUES (%s)" %
460                      (me._table,
461                       ', '.join([me._user, me._passwd] + me._fields),
462                       ', '.join(['$%s' % t for t in tags])),
463                      **dict(I.izip(tags, f)))
464
465   def _remove(me, rec):
466     """Remove the record REC from the database."""
467     me._connect()
468     with me._db:
469       me._db.execute("DELETE FROM %s WHERE %s = $user" %
470                      (me._table, me._user),
471                      user = rec.user)
472
473   def _update(me, rec):
474     """Update the record REC in the database."""
475     me._connect()
476     with me._db:
477       me._db.execute(
478         "UPDATE %s SET %s = $passwd WHERE %s = $user" % (
479           me._table, me._passwd, me._user),
480         user = rec.user, passwd = rec.passwd)
481
482 CONF.export('DatabaseBackend')
483
484 ###----- That's all, folks --------------------------------------------------