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, user, passwd, args):
49 Return a vector of filled-in fields.
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'.
58 ## Prepare the result vector, and set up some data structures.
61 rmap = map(int, xrange(n))
63 if fno_user >= n or fno_passwd >= n: ok = False
69 raise U.ExpectedError, \
70 (500, "Fields specified aren't contiguous")
72 ## Prepare the new record's fields.
75 f[fno_passwd] = passwd
79 k, v = a.split('=', 1)
81 except KeyError: raise U.ExpectedError, (400, "Unknown field `%s'" % k)
84 if f[i] is None: break
86 raise U.ExpectedError, (500, "All fields already populated")
89 raise U.ExpectedError, (400, "Field %s is already set" % rmap[i])
92 ## Check that the vector of fields is properly set up.
95 raise U.ExpectedError, (500, "Field %s is unset" % rmap[i])
100 ###--------------------------------------------------------------------------
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.
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
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.
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)
127 class BasicRecord (object):
129 A handy base class for record classes.
131 Keep track of the backend in `_be', and call its `_update' method to write
134 def __init__(me, backend):
141 class TrivialRecord (BasicRecord):
143 A trivial record which simply remembers `user' and `passwd' attributes.
145 Additional attributes can be set on the object if this is convenient.
147 def __init__(me, user, passwd, *args, **kw):
148 super(TrivialRecord, me).__init__(*args, **kw)
152 ###--------------------------------------------------------------------------
155 class FlatFileRecord (BasicRecord):
157 A record from a flat-file database (like a passwd(5) file).
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.
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).
168 def __init__(me, line, delim, fmap, *args, **kw):
170 Initialize the record, splitting the LINE into fields separated by DELIM,
171 and setting attributes under control of FMAP.
173 super(FlatFileRecord, me).__init__(*args, **kw)
174 line = line.rstrip('\n')
175 fields = line.split(delim)
179 for k, v in fmap.iteritems():
180 setattr(me, k, fields[v])
184 Format the record as a line of text.
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.
191 for k, v in me._fmap.iteritems():
193 for badch, what in [(me._delim, "delimiter `%s'" % me._delim),
194 ('\n', 'newline character'),
195 ('\0', 'null character')]:
197 raise U.ExpectedError, \
198 (500, "New `%s' field contains %s" % (k, what))
200 return me._delim.join(fields) + '\n'
202 class FlatFileBackend (object):
204 Password storage in a flat passwd(5)-style file.
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.
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
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.
226 def __init__(me, file, lock = None,
227 delim = ':', user = 0, passwd = 1, **fields):
229 Construct a new flat-file backend object. See the class documentation
235 fmap = dict(user = user, passwd = passwd)
236 for k, v in fields.iteritems(): fmap['f_' + k] = v
239 def lookup(me, user):
240 """Return the record for the named USER."""
241 with open(me._file) as f:
243 rec = me._parse(line)
246 raise UnknownUser, user
248 def create(me, user, passwd, args):
250 Create a new record for the USER.
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
258 f = fill_in_fields(me._fmap['user'], me._fmap['passwd'],
260 for k, i in me._fmap.iteritems()
261 if k.startswith('f_')],
263 r = FlatFileRecord(me._delim.join(f), me._delim, me._fmap, backend = me)
264 me._rewrite('create', r)
266 def _rewrite(me, op, rec):
268 Rewrite the file, according to OP.
270 The OP may be one of the following.
272 `create' There must not be a record matching REC; add a new
275 `remove' There must be a record matching REC: remove it.
277 `update' There must be a record matching REC: write REC in its
281 ## The main update function.
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
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)
291 ## This is the fiddly bit.
295 ## Copy the old file to the new one, changing the user's record if
296 ## and when we encounter it.
298 with OS.fdopen(fd, 'w') as f_out:
299 with open(me._file) as f_in:
302 if r.user != rec.user:
305 raise U.ExpectedError, \
306 (500, "Record for `%s' already exists" % rec.user)
309 if op != 'remove': f_out.write(rec._format())
313 f_out.write(rec._format())
315 raise U.ExpectedError, \
316 (500, "Record for `%s' not found" % rec.user)
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)
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))
329 ## Don't try to delete the new file if we succeeded: it might belong
330 ## to another instance of us.
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)
340 def dolocked(me, lock, func):
342 Call FUNC with the LOCK held.
344 Subclasses can override this method in order to provide alternative
345 locking functionality.
347 try: OS.mkdir(CFG.LOCKDIR)
349 if e.errno != E.EEXIST: raise
350 with U.lockfile(OS.path.join(CFG.LOCKDIR, lock), 5):
353 def _parse(me, line):
354 """Convenience function for constructing a record."""
355 return FlatFileRecord(line, me._delim, me._fmap, backend = me)
357 def _update(me, rec):
358 """Update the record REC in the file."""
359 me._rewrite('update', rec)
361 def _remove(me, rec):
362 """Update the record REC in the file."""
363 me._rewrite('remove', rec)
365 CONF.export('FlatFileBackend')
367 ###--------------------------------------------------------------------------
370 class DatabaseBackend (object):
372 Password storage in a SQL database table.
374 We assume that there's a single table mapping user names to (hashed)
375 passwords: we won't try anything complicated involving joins.
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.
385 def __init__(me, modname, modargs, table, user, passwd, *fields):
387 Create a database backend object. See the class docstring for details.
392 me._fields = list(fields)
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
398 me._modname = modname
399 me._modargs = modargs
402 """Set up the lazy connection to the database."""
404 me._db = U.SimpleDBConnection(me._modname, me._modargs)
406 def lookup(me, user):
407 """Return the record for the named USER."""
409 me._db.execute("SELECT %s FROM %s WHERE %s = $user" %
410 (', '.join([me._passwd] + me._fields),
411 me._table, me._user),
413 row = me._db.fetchone()
414 if row is None: raise UnknownUser, user
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)
421 def create(me, user, passwd, args):
423 Create a new record for the named USER.
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.
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))),
437 me._db.execute("INSERT INTO %s (%s) VALUES (%s)" %
439 ', '.join([me._user, me._passwd] + me._fields),
440 ', '.join(['$%s' % t for t in tags])),
441 **dict(I.izip(tags, f)))
443 def _remove(me, rec):
444 """Remove the record REC from the database."""
447 me._db.execute("DELETE FROM %s WHERE %s = $user" %
448 (me._table, me._user),
451 def _update(me, rec):
452 """Update the record REC in the database."""
456 "UPDATE %s SET %s = $passwd WHERE %s = $user" % (
457 me._table, me._passwd, me._user),
458 user = rec.user, passwd = rec.passwd)
460 CONF.export('DatabaseBackend')
462 ###----- That's all, folks --------------------------------------------------