chiark / gitweb /
1967cda5dd76b2440331fd5358fe49e9d3b049dc
[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 import os as OS; ENV = OS.environ
29
30 import config as CONF; CFG = CONF.CFG
31 import util as U
32
33 ###--------------------------------------------------------------------------
34 ### Relevant configuration.
35
36 CONF.DEFAULTS.update(
37
38   ## A directory in which we can create lockfiles.
39   LOCKDIR = OS.path.join(ENV['HOME'], 'var', 'lock', 'chpwd'))
40
41 ###--------------------------------------------------------------------------
42 ### Protocol.
43 ###
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.
47 ###
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
55 ### database.
56 ###
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.
61
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)
66     me.user = user
67
68 class BasicRecord (object):
69   """
70   A handy base class for record classes.
71
72   Keep track of the backend in `_be', and call its `_update' method to write
73   ourselves back.
74   """
75   def __init__(me, backend):
76     me._be = backend
77   def write(me):
78     me._be._update(me)
79
80 class TrivialRecord (BasicRecord):
81   """
82   A trivial record which simply remembers `user' and `passwd' attributes.
83
84   Additional attributes can be set on the object if this is convenient.
85   """
86   def __init__(me, user, passwd, *args, **kw):
87     super(TrivialRecord, me).__init__(*args, **kw)
88     me.user = user
89     me.passwd = passwd
90
91 ###--------------------------------------------------------------------------
92 ### Flat files.
93
94 class FlatFileRecord (BasicRecord):
95   """
96   A record from a flat-file database (like a passwd(5) file).
97
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.
100
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).
105   """
106
107   def __init__(me, line, delim, fmap, *args, **kw):
108     """
109     Initialize the record, splitting the LINE into fields separated by DELIM,
110     and setting attributes under control of FMAP.
111     """
112     super(FlatFileRecord, me).__init__(*args, **kw)
113     line = line.rstrip('\n')
114     fields = line.split(delim)
115     me._delim = delim
116     me._fmap = fmap
117     me._raw = fields
118     for k, v in fmap.iteritems():
119       setattr(me, k, fields[v])
120
121   def _format(me):
122     """
123     Format the record as a line of text.
124
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.
128     """
129     fields = me._raw
130     for k, v in me._fmap.iteritems():
131       val = getattr(me, k)
132       for badch, what in [(me._delim, "delimiter `%s'" % me._delim),
133                           ('\n', 'newline character'),
134                           ('\0', 'null character')]:
135         if badch in val:
136           raise U.ExpectedError, \
137                 (500, "New `%s' field contains %s" % (k, what))
138       fields[v] = val
139     return me._delim.join(fields) + '\n'
140
141 class FlatFileBackend (object):
142   """
143   Password storage in a flat passwd(5)-style file.
144
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.
148
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.
154
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.
160   """
161
162   def __init__(me, file, lock = None,
163                delim = ':', user = 0, passwd = 1, **fields):
164     """
165     Construct a new flat-file backend object.  See the class documentation
166     for details.
167     """
168     me._lock = lock
169     me._file = file
170     me._delim = delim
171     fmap = dict(user = user, passwd = passwd)
172     for k, v in fields.iteritems(): fmap['f_' + k] = v
173     me._fmap = fmap
174
175   def lookup(me, user):
176     """Return the record for the named USER."""
177     with open(me._file) as f:
178       for line in f:
179         rec = me._parse(line)
180         if rec.user == user:
181           return rec
182     raise UnknownUser, user
183
184   def _update(me, rec):
185     """Update the record REC in the file."""
186
187     ## The main update function.
188     def doit():
189
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
192       ## the old one.
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)
196
197       ## This is the fiddly bit.
198       lose = True
199       try:
200
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:
205             for line in f_in:
206               r = me._parse(line)
207               if r.user != rec.user:
208                 f_out.write(line)
209               else:
210                 f_out.write(rec._format())
211
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)
217         lose = False
218       except OSError, e:
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))
222       finally:
223         ## Don't try to delete the new file if we succeeded: it might belong
224         ## to another instance of us.
225         if lose:
226           try: OS.unlink(tmp)
227           except: pass
228
229     ## If there's a lockfile, then acquire it around the meat of this
230     ## function; otherwise just do the job.
231     if me._lock is None:
232       doit()
233     else:
234       with U.lockfile(OS.path.join(CFG.LOCKDIR, me._lock), 5):
235         doit()
236
237   def _parse(me, line):
238     """Convenience function for constructing a record."""
239     return FlatFileRecord(line, me._delim, me._fmap, backend = me)
240
241 CONF.export('FlatFileBackend')
242
243 ###--------------------------------------------------------------------------
244 ### SQL databases.
245
246 class DatabaseBackend (object):
247   """
248   Password storage in a SQL database table.
249
250   We assume that there's a single table mapping user names to (hashed)
251   passwords: we won't try anything complicated involving joins.
252
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.
259   """
260
261   def __init__(me, modname, modargs, table, user, passwd, *fields):
262     """
263     Create a database backend object.  See the class docstring for details.
264     """
265     me._table = table
266     me._user = user
267     me._passwd = passwd
268     me._fields = list(fields)
269
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
272     ## to use one.
273     me._db = None
274     me._modname = modname
275     me._modargs = modargs
276
277   def _connect(me):
278     """Set up the lazy connection to the database."""
279     if me._db is None:
280       me._db = U.SimpleDBConnection(me._modname, me._modargs)
281
282   def lookup(me, user):
283     """Return the record for the named USER."""
284     me._connect()
285     me._db.execute("SELECT %s FROM %s WHERE %s = $user" %
286                    (', '.join([me._passwd] + me._fields),
287                     me._table, me._user),
288                    user = user)
289     row = me._db.fetchone()
290     if row is None: raise UnknownUser, user
291     passwd = row[0]
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)
295     return rec
296
297   def _update(me, rec):
298     """Update the record REC in the database."""
299     me._connect()
300     with me._db:
301       me._db.execute(
302         "UPDATE %s SET %s = $passwd WHERE %s = $user" % (
303           me._table, me._passwd, me._user),
304         user = rec.user, passwd = rec.passwd)
305
306 CONF.export('DatabaseBackend')
307
308 ###----- That's all, folks --------------------------------------------------