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
28 import os as OS; ENV = OS.environ
30 import config as CONF; CFG = CONF.CFG
33 ###--------------------------------------------------------------------------
34 ### Relevant configuration.
38 ## A directory in which we can create lockfiles.
39 LOCKDIR = OS.path.join(ENV['HOME'], 'var', 'lock', 'chpwd'))
41 ###--------------------------------------------------------------------------
44 ### A password backend knows how to fetch and modify records in some password
45 ### database, e.g., a flat passwd(5)-style password file, or a table in some
46 ### proper grown-up SQL database.
48 ### A backend's `lookup' method retrieves the record for a named user from
49 ### the database, returning it in a record object, or raises `UnknownUser'.
50 ### The record object maintains `user' (the user name, as supplied to
51 ### `lookup') and `passwd' (the encrypted password, in whatever form the
52 ### underlying database uses) attributes, and possibly others. The `passwd'
53 ### attribute (at least) may be modified by the caller. The record object
54 ### has a `write' method, which updates the corresponding record in the
57 ### The concrete record objects defined here inherit from `BasicRecord',
58 ### which keeps track of its parent backend, and implements `write' by
59 ### calling the backend's `_update' method. Some backends require that their
60 ### record objects implement additional private protocols.
62 class UnknownUser (U.ExpectedError):
63 """The named user wasn't found in the database."""
64 def __init__(me, user):
65 U.ExpectedError.__init__(me, 500, "Unknown user `%s'" % user)
68 class BasicRecord (object):
70 A handy base class for record classes.
72 Keep track of the backend in `_be', and call its `_update' method to write
75 def __init__(me, backend):
80 class TrivialRecord (BasicRecord):
82 A trivial record which simply remembers `user' and `passwd' attributes.
84 Additional attributes can be set on the object if this is convenient.
86 def __init__(me, user, passwd, *args, **kw):
87 super(TrivialRecord, me).__init__(*args, **kw)
91 ###--------------------------------------------------------------------------
94 class FlatFileRecord (BasicRecord):
96 A record from a flat-file database (like a passwd(5) file).
98 Such a file carries one record per line; each record is split into fields
99 by a delimiter character, specified by the DELIM constructor argument.
101 The FMAP argument to the constructor maps names to field index numbers.
102 The standard `user' and `passwd' fields must be included in this map if the
103 object is to implement the protocol correctly (though the `FlatFileBackend'
104 is careful to do this).
107 def __init__(me, line, delim, fmap, *args, **kw):
109 Initialize the record, splitting the LINE into fields separated by DELIM,
110 and setting attributes under control of FMAP.
112 super(FlatFileRecord, me).__init__(*args, **kw)
113 line = line.rstrip('\n')
114 fields = line.split(delim)
118 for k, v in fmap.iteritems():
119 setattr(me, k, fields[v])
123 Format the record as a line of text.
125 The flat-file format is simple, but rather fragile with respect to
126 invalid characters, and often processed by substandard software, so be
127 careful not to allow bad characters into the file.
130 for k, v in me._fmap.iteritems():
132 for badch, what in [(me._delim, "delimiter `%s'" % me._delim),
133 ('\n', 'newline character'),
134 ('\0', 'null character')]:
136 raise U.ExpectedError, \
137 (500, "New `%s' field contains %s" % (k, what))
139 return me._delim.join(fields) + '\n'
141 class FlatFileBackend (object):
143 Password storage in a flat passwd(5)-style file.
145 The FILE constructor argument names the file. Such a file carries one
146 record per line; each record is split into fields by a delimiter character,
147 specified by the DELIM constructor argument.
149 The file is updated by writing a new version alongside, as `FILE.new', and
150 renaming it over the old version. If a LOCK file is named then an
151 exclusive fcntl(2)-style lock is taken out on `LOCKDIR/LOCK' (creating the
152 file if necessary) during the update operation. Use of a lockfile is
153 strongly recommended.
155 The DELIM constructor argument specifies the delimiter character used when
156 splitting lines into fields. The USER and PASSWD arguments give the field
157 numbers (starting from 0) for the user-name and hashed-password fields;
158 additional field names may be given using keyword arguments: the values of
159 these fields are exposed as attributes `f_NAME' on record objects.
162 def __init__(me, file, lock = None,
163 delim = ':', user = 0, passwd = 1, **fields):
165 Construct a new flat-file backend object. See the class documentation
171 fmap = dict(user = user, passwd = passwd)
172 for k, v in fields.iteritems(): fmap['f_' + k] = v
175 def lookup(me, user):
176 """Return the record for the named USER."""
177 with open(me._file) as f:
179 rec = me._parse(line)
182 raise UnknownUser, user
184 def _update(me, rec):
185 """Update the record REC in the file."""
187 ## The main update function.
190 ## Make sure we preserve the file permissions, and in particular don't
191 ## allow a window during which the new file has looser permissions than
193 st = OS.stat(me._file)
194 tmp = me._file + '.new'
195 fd = OS.open(tmp, OS.O_WRONLY | OS.O_CREAT | OS.O_EXCL, st.st_mode)
197 ## This is the fiddly bit.
201 ## Copy the old file to the new one, changing the user's record if
202 ## and when we encounter it.
203 with OS.fdopen(fd, 'w') as f_out:
204 with open(me._file) as f_in:
207 if r.user != rec.user:
210 f_out.write(rec._format())
212 ## Update the permissions on the new file. Don't try to fix the
213 ## ownership (we shouldn't be running as root) or the group (the
214 ## parent directory should have the right permissions already).
215 OS.chmod(tmp, st.st_mode)
216 OS.rename(tmp, me._file)
219 ## I suppose that system errors are to be expected at this point.
220 raise U.ExpectedError, \
221 (500, "Failed to update `%s': %s" % (me._file, e))
223 ## Don't try to delete the new file if we succeeded: it might belong
224 ## to another instance of us.
229 ## If there's a lockfile, then acquire it around the meat of this
230 ## function; otherwise just do the job.
234 with U.lockfile(OS.path.join(CFG.LOCKDIR, me._lock), 5):
237 def _parse(me, line):
238 """Convenience function for constructing a record."""
239 return FlatFileRecord(line, me._delim, me._fmap, backend = me)
241 CONF.export('FlatFileBackend')
243 ###--------------------------------------------------------------------------
246 class DatabaseBackend (object):
248 Password storage in a SQL database table.
250 We assume that there's a single table mapping user names to (hashed)
251 passwords: we won't try anything complicated involving joins.
253 We need to know a database module MODNAME and arguments MODARGS to pass to
254 the `connect' function. We also need to know the TABLE to search, and the
255 USER and PASSWD field names. Additional field names can be passed to the
256 constructor: these will be read from the database and attached as
257 attributes `f_NAME' to the record returned by `lookup'. Changes to these
258 attributes are currently not propagated back to the database.
261 def __init__(me, modname, modargs, table, user, passwd, *fields):
263 Create a database backend object. See the class docstring for details.
268 me._fields = list(fields)
270 ## We don't connect immediately. That would be really bad if we had lots
271 ## of database backends running at a time, because we probably only want
274 me._modname = modname
275 me._modargs = modargs
278 """Set up the lazy connection to the database."""
280 me._db = U.SimpleDBConnection(me._modname, me._modargs)
282 def lookup(me, user):
283 """Return the record for the named USER."""
285 me._db.execute("SELECT %s FROM %s WHERE %s = $user" %
286 (', '.join([me._passwd] + me._fields),
287 me._table, me._user),
289 row = me._db.fetchone()
290 if row is None: raise UnknownUser, user
292 rec = TrivialRecord(backend = me, user = user, passwd = passwd)
293 for f, v in zip(me._fields, row[1:]):
294 setattr(rec, 'f_' + f, v)
297 def _update(me, rec):
298 """Update the record REC in the database."""
302 "UPDATE %s SET %s = $passwd WHERE %s = $user" % (
303 me._table, me._passwd, me._user),
304 user = rec.user, passwd = rec.passwd)
306 CONF.export('DatabaseBackend')
308 ###----- That's all, folks --------------------------------------------------