wrapper.fhtml: Add `license' relationship to the AGPL link.
[chopwood] / backend.py
CommitLineData
a2916c06
MW
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
26from __future__ import with_statement
27
e32b221f
MW
28from auto import HOME
29import errno as E
82d4f64b 30import itertools as I
a2916c06
MW
31import os as OS; ENV = OS.environ
32
33import config as CONF; CFG = CONF.CFG
34import util as U
35
36###--------------------------------------------------------------------------
37### Relevant configuration.
38
39CONF.DEFAULTS.update(
40
41 ## A directory in which we can create lockfiles.
d9ca01b9 42 LOCKDIR = OS.path.join(HOME, 'lock'))
a2916c06
MW
43
44###--------------------------------------------------------------------------
82d4f64b
MW
45### Utilities.
46
47def 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###--------------------------------------------------------------------------
a2916c06
MW
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
121class 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
127class 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)
82d4f64b
MW
138 def remove(me):
139 me._be._remove(me)
a2916c06
MW
140
141class 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
155class 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
1f8350d2 200 return me._delim.join(fields) + '\n'
a2916c06
MW
201
202class 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
e32b221f
MW
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.
a2916c06
MW
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
82d4f64b
MW
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)
c81f8191 263 r = FlatFileRecord(me._delim.join(f), me._delim, me._fmap, backend = me)
82d4f64b
MW
264 me._rewrite('create', r)
265
612419ac
MW
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 """
a2916c06
MW
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.
612419ac 297 found = False
a2916c06
MW
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)
612419ac
MW
304 elif op == 'create':
305 raise U.ExpectedError, \
306 (500, "Record for `%s' already exists" % rec.user)
a2916c06 307 else:
612419ac
MW
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)
a2916c06
MW
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
74b87214 335 ## If there's a lockfile, then acquire it around the meat of this
a2916c06 336 ## function; otherwise just do the job.
e32b221f
MW
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()
a2916c06
MW
352
353 def _parse(me, line):
354 """Convenience function for constructing a record."""
355 return FlatFileRecord(line, me._delim, me._fmap, backend = me)
356
612419ac
MW
357 def _update(me, rec):
358 """Update the record REC in the file."""
359 me._rewrite('update', rec)
360
82d4f64b
MW
361 def _remove(me, rec):
362 """Update the record REC in the file."""
363 me._rewrite('remove', rec)
364
a2916c06
MW
365CONF.export('FlatFileBackend')
366
367###--------------------------------------------------------------------------
368### SQL databases.
369
370class 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
82d4f64b
MW
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
a2916c06
MW
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
460CONF.export('DatabaseBackend')
461
462###----- That's all, folks --------------------------------------------------