5 ### (c) 2013 Mark Wooding
8 ###----- Licensing notice ---------------------------------------------------
10 ### This file is part of Chopwood: a password-changing service.
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.
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.
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/>.
26 from __future__ import with_statement
31 import os as OS; ENV = OS.environ
33 import config as CONF; CFG = CONF.CFG
36 ###--------------------------------------------------------------------------
37 ### Relevant configuration.
41 ## A directory in which we can create lockfiles.
42 LOCKDIR = OS.path.join(HOME, 'lock'))
44 ###--------------------------------------------------------------------------
47 def fill_in_fields(fno_user, fno_passwd, fno_map,
48 user, passwd, args, defaults = None):
50 Return a vector of filled-in fields.
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'.
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.
63 ## Prepare the result vector, and set up some data structures.
66 rmap = map(int, xrange(n))
68 if fno_user >= n or fno_passwd >= n: ok = False
71 if i in rmap: ok = False
72 else: rmap[i] = "`%s'" % k
75 raise U.ExpectedError, \
76 (500, "Fields specified aren't contiguous")
78 ## Prepare the new record's fields.
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")
89 k, v = a.split('=', 1)
91 except KeyError: raise U.ExpectedError, (400, "Unknown field `%s'" % k)
94 if f[i] is None: break
96 raise U.ExpectedError, (500, "All fields already populated")
99 raise U.ExpectedError, (400, "Field %s is already set" % rmap[i])
102 ## Check that the vector of fields is properly set up. Copy unset values
103 ## from the defaults if they're available.
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])
112 ###--------------------------------------------------------------------------
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.
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
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.
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)
139 class BasicRecord (object):
141 A handy base class for record classes.
143 Keep track of the backend in `_be', and call its `_update' method to write
146 def __init__(me, backend):
153 class TrivialRecord (BasicRecord):
155 A trivial record which simply remembers `user' and `passwd' attributes.
157 Additional attributes can be set on the object if this is convenient.
159 def __init__(me, user, passwd, *args, **kw):
160 super(TrivialRecord, me).__init__(*args, **kw)
164 ###--------------------------------------------------------------------------
167 class FlatFileRecord (BasicRecord):
169 A record from a flat-file database (like a passwd(5) file).
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.
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).
180 def __init__(me, line, delim, fno_user, fno_passwd, fmap, *args, **kw):
182 Initialize the record, splitting the LINE into fields separated by DELIM,
183 and setting attributes under control of FMAP.
185 super(FlatFileRecord, me).__init__(*args, **kw)
186 line = line.rstrip('\n')
187 fields = line.split(delim)
189 me._fno_user = fno_user
190 me._fno_passwd = fno_passwd
193 for k, v in fmap.iteritems():
194 setattr(me, k, fields[v])
198 Format the record as a line of text.
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.
205 for k, v in me._fmap.iteritems():
207 for badch, what in [(me._delim, "delimiter `%s'" % me._delim),
208 ('\n', 'newline character'),
209 ('\0', 'null character')]:
211 raise U.ExpectedError, \
212 (500, "New `%s' field contains %s" % (k, what))
214 return me._delim.join(fields) + '\n'
218 Modify the record as described by the ARGS.
220 Fields other than the user name and password can be modified.
224 class FlatFileBackend (object):
226 Password storage in a flat passwd(5)-style file.
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.
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
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.
248 def __init__(me, file, lock = None,
249 delim = ':', user = 0, passwd = 1, **fields):
251 Construct a new flat-file backend object. See the class documentation
257 fmap = dict(user = user, passwd = passwd)
258 for k, v in fields.iteritems(): fmap['f_' + k] = v
261 def lookup(me, user):
262 """Return the record for the named USER."""
263 with open(me._file) as f:
265 rec = me._parse(line)
268 raise UnknownUser, user
270 def create(me, user, passwd, args):
272 Create a new record for the USER.
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
280 f = fill_in_fields(me._fmap['user'], me._fmap['passwd'],
282 for k, i in me._fmap.iteritems()
283 if k.startswith('f_')],
285 r = FlatFileRecord(me._delim.join(f), me._delim, me._fmap, backend = me)
286 me._rewrite('create', r)
288 def _rewrite(me, op, rec):
290 Rewrite the file, according to OP.
292 The OP may be one of the following.
294 `create' There must not be a record matching REC; add a new
297 `remove' There must be a record matching REC: remove it.
299 `update' There must be a record matching REC: write REC in its
303 ## The main update function.
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
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)
313 ## This is the fiddly bit.
317 ## Copy the old file to the new one, changing the user's record if
318 ## and when we encounter it.
320 with OS.fdopen(fd, 'w') as f_out:
321 with open(me._file) as f_in:
324 if r.user != rec.user:
327 raise U.ExpectedError, \
328 (500, "Record for `%s' already exists" % rec.user)
331 if op != 'remove': f_out.write(rec._format())
335 f_out.write(rec._format())
337 raise U.ExpectedError, \
338 (500, "Record for `%s' not found" % rec.user)
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)
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))
351 ## Don't try to delete the new file if we succeeded: it might belong
352 ## to another instance of us.
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)
362 def dolocked(me, lock, func):
364 Call FUNC with the LOCK held.
366 Subclasses can override this method in order to provide alternative
367 locking functionality.
369 try: OS.mkdir(CFG.LOCKDIR)
371 if e.errno != E.EEXIST: raise
372 with U.lockfile(OS.path.join(CFG.LOCKDIR, lock), 5):
375 def _parse(me, line):
376 """Convenience function for constructing a record."""
377 return FlatFileRecord(line, me._delim, me._fmap, backend = me)
379 def _update(me, rec):
380 """Update the record REC in the file."""
381 me._rewrite('update', rec)
383 def _remove(me, rec):
384 """Update the record REC in the file."""
385 me._rewrite('remove', rec)
387 CONF.export('FlatFileBackend')
389 ###--------------------------------------------------------------------------
392 class DatabaseBackend (object):
394 Password storage in a SQL database table.
396 We assume that there's a single table mapping user names to (hashed)
397 passwords: we won't try anything complicated involving joins.
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.
407 def __init__(me, modname, modargs, table, user, passwd, *fields):
409 Create a database backend object. See the class docstring for details.
414 me._fields = list(fields)
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
420 me._modname = modname
421 me._modargs = modargs
424 """Set up the lazy connection to the database."""
426 me._db = U.SimpleDBConnection(me._modname, me._modargs)
428 def lookup(me, user):
429 """Return the record for the named USER."""
431 me._db.execute("SELECT %s FROM %s WHERE %s = $user" %
432 (', '.join([me._passwd] + me._fields),
433 me._table, me._user),
435 row = me._db.fetchone()
436 if row is None: raise UnknownUser, user
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)
443 def create(me, user, passwd, args):
445 Create a new record for the named USER.
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.
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))),
459 me._db.execute("INSERT INTO %s (%s) VALUES (%s)" %
461 ', '.join([me._user, me._passwd] + me._fields),
462 ', '.join(['$%s' % t for t in tags])),
463 **dict(I.izip(tags, f)))
465 def _remove(me, rec):
466 """Remove the record REC from the database."""
469 me._db.execute("DELETE FROM %s WHERE %s = $user" %
470 (me._table, me._user),
473 def _update(me, rec):
474 """Update the record REC in the database."""
478 "UPDATE %s SET %s = $passwd WHERE %s = $user" % (
479 me._table, me._passwd, me._user),
480 user = rec.user, passwd = rec.passwd)
482 CONF.export('DatabaseBackend')
484 ###----- That's all, folks --------------------------------------------------