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