chiark / gitweb /
chpwd.css, login.fhtml: Move the login whinge to underneath the widgets.
[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
82d4f64b 28import itertools as I
a2916c06
MW
29import os as OS; ENV = OS.environ
30
31import config as CONF; CFG = CONF.CFG
32import util as U
33
34###--------------------------------------------------------------------------
35### Relevant configuration.
36
37CONF.DEFAULTS.update(
38
39 ## A directory in which we can create lockfiles.
40 LOCKDIR = OS.path.join(ENV['HOME'], 'var', 'lock', 'chpwd'))
41
82d4f64b
MW
42###--------------------------------------------------------------------------
43### Utilities.
44
45def fill_in_fields(fno_user, fno_passwd, fno_map, user, passwd, args):
46 """
47 Return a vector of filled-in fields.
48
49 The FNO_... arguments give field numbers: FNO_USER and FNO_PASSWD give the
50 positions for the username and password fields, respectively; and FNO_MAP
51 is a sequence of (NAME, POS) pairs. The USER and PASSWD arguments give the
52 actual user name and password values; ARGS are the remaining arguments,
53 maybe in the form `NAME=VALUE'.
54 """
55
56 ## Prepare the result vector, and set up some data structures.
57 n = 2 + len(fno_map)
58 fmap = {}
59 rmap = map(int, xrange(n))
60 ok = True
61 if fno_user >= n or fno_passwd >= n: ok = False
62 for k, i in fno_map:
63 fmap[k] = i
64 rmap[i] = "`%s'" % k
65 if i >= n: ok = False
66 if not ok:
67 raise U.ExpectedError, \
68 (500, "Fields specified aren't contiguous")
69
70 ## Prepare the new record's fields.
71 f = [None]*n
72 f[fno_user] = user
73 f[fno_passwd] = passwd
74
75 for a in args:
76 if '=' in a:
77 k, v = a.split('=', 1)
78 try: i = fmap[k]
79 except KeyError: raise U.ExpectedError, (400, "Unknown field `%s'" % k)
80 else:
81 for i in xrange(n):
82 if f[i] is None: break
83 else:
84 raise U.ExpectedError, (500, "All fields already populated")
85 v = a
86 if f[i] is not None:
87 raise U.ExpectedError, (400, "Field %s is already set" % rmap[i])
88 f[i] = v
89
90 ## Check that the vector of fields is properly set up.
91 for i in xrange(n):
92 if f[i] is None:
93 raise U.ExpectedError, (500, "Field %s is unset" % rmap[i])
94
95 ## Done.
96 return f
97
a2916c06
MW
98###--------------------------------------------------------------------------
99### Protocol.
100###
101### A password backend knows how to fetch and modify records in some password
102### database, e.g., a flat passwd(5)-style password file, or a table in some
103### proper grown-up SQL database.
104###
105### A backend's `lookup' method retrieves the record for a named user from
106### the database, returning it in a record object, or raises `UnknownUser'.
107### The record object maintains `user' (the user name, as supplied to
108### `lookup') and `passwd' (the encrypted password, in whatever form the
109### underlying database uses) attributes, and possibly others. The `passwd'
110### attribute (at least) may be modified by the caller. The record object
111### has a `write' method, which updates the corresponding record in the
112### database.
113###
114### The concrete record objects defined here inherit from `BasicRecord',
115### which keeps track of its parent backend, and implements `write' by
116### calling the backend's `_update' method. Some backends require that their
117### record objects implement additional private protocols.
118
119class UnknownUser (U.ExpectedError):
120 """The named user wasn't found in the database."""
121 def __init__(me, user):
122 U.ExpectedError.__init__(me, 500, "Unknown user `%s'" % user)
123 me.user = user
124
125class BasicRecord (object):
126 """
127 A handy base class for record classes.
128
129 Keep track of the backend in `_be', and call its `_update' method to write
130 ourselves back.
131 """
132 def __init__(me, backend):
133 me._be = backend
134 def write(me):
135 me._be._update(me)
82d4f64b
MW
136 def remove(me):
137 me._be._remove(me)
a2916c06
MW
138
139class TrivialRecord (BasicRecord):
140 """
141 A trivial record which simply remembers `user' and `passwd' attributes.
142
143 Additional attributes can be set on the object if this is convenient.
144 """
145 def __init__(me, user, passwd, *args, **kw):
146 super(TrivialRecord, me).__init__(*args, **kw)
147 me.user = user
148 me.passwd = passwd
149
150###--------------------------------------------------------------------------
151### Flat files.
152
153class FlatFileRecord (BasicRecord):
154 """
155 A record from a flat-file database (like a passwd(5) file).
156
157 Such a file carries one record per line; each record is split into fields
158 by a delimiter character, specified by the DELIM constructor argument.
159
160 The FMAP argument to the constructor maps names to field index numbers.
161 The standard `user' and `passwd' fields must be included in this map if the
162 object is to implement the protocol correctly (though the `FlatFileBackend'
163 is careful to do this).
164 """
165
166 def __init__(me, line, delim, fmap, *args, **kw):
167 """
168 Initialize the record, splitting the LINE into fields separated by DELIM,
169 and setting attributes under control of FMAP.
170 """
171 super(FlatFileRecord, me).__init__(*args, **kw)
172 line = line.rstrip('\n')
173 fields = line.split(delim)
174 me._delim = delim
175 me._fmap = fmap
176 me._raw = fields
177 for k, v in fmap.iteritems():
178 setattr(me, k, fields[v])
179
180 def _format(me):
181 """
182 Format the record as a line of text.
183
184 The flat-file format is simple, but rather fragile with respect to
185 invalid characters, and often processed by substandard software, so be
186 careful not to allow bad characters into the file.
187 """
188 fields = me._raw
189 for k, v in me._fmap.iteritems():
190 val = getattr(me, k)
191 for badch, what in [(me._delim, "delimiter `%s'" % me._delim),
192 ('\n', 'newline character'),
193 ('\0', 'null character')]:
194 if badch in val:
195 raise U.ExpectedError, \
196 (500, "New `%s' field contains %s" % (k, what))
197 fields[v] = val
1f8350d2 198 return me._delim.join(fields) + '\n'
a2916c06
MW
199
200class FlatFileBackend (object):
201 """
202 Password storage in a flat passwd(5)-style file.
203
204 The FILE constructor argument names the file. Such a file carries one
205 record per line; each record is split into fields by a delimiter character,
206 specified by the DELIM constructor argument.
207
208 The file is updated by writing a new version alongside, as `FILE.new', and
209 renaming it over the old version. If a LOCK file is named then an
210 exclusive fcntl(2)-style lock is taken out on `LOCKDIR/LOCK' (creating the
211 file if necessary) during the update operation. Use of a lockfile is
212 strongly recommended.
213
214 The DELIM constructor argument specifies the delimiter character used when
215 splitting lines into fields. The USER and PASSWD arguments give the field
216 numbers (starting from 0) for the user-name and hashed-password fields;
217 additional field names may be given using keyword arguments: the values of
218 these fields are exposed as attributes `f_NAME' on record objects.
219 """
220
221 def __init__(me, file, lock = None,
222 delim = ':', user = 0, passwd = 1, **fields):
223 """
224 Construct a new flat-file backend object. See the class documentation
225 for details.
226 """
227 me._lock = lock
228 me._file = file
229 me._delim = delim
230 fmap = dict(user = user, passwd = passwd)
231 for k, v in fields.iteritems(): fmap['f_' + k] = v
232 me._fmap = fmap
233
234 def lookup(me, user):
235 """Return the record for the named USER."""
236 with open(me._file) as f:
237 for line in f:
238 rec = me._parse(line)
239 if rec.user == user:
240 return rec
241 raise UnknownUser, user
242
82d4f64b
MW
243 def create(me, user, passwd, args):
244 """
245 Create a new record for the USER.
246
247 The new record has the given PASSWD, and other fields are set from ARGS.
248 Those ARGS of the form `KEY=VALUE' set the appropriately named fields (as
249 set up by the constructor); other ARGS fill in unset fields, left to
250 right.
251 """
252
253 f = fill_in_fields(me._fmap['user'], me._fmap['passwd'],
254 [(k[2:], i)
255 for k, i in me._fmap.iteritems()
256 if k.startswith('f_')],
257 user, passwd, args)
258 r = FlatFileRecord(':'.join(f), me._delim, me._fmap, backend = me)
259 me._rewrite('create', r)
260
612419ac
MW
261 def _rewrite(me, op, rec):
262 """
263 Rewrite the file, according to OP.
264
265 The OP may be one of the following.
266
267 `create' There must not be a record matching REC; add a new
268 one.
269
270 `remove' There must be a record matching REC: remove it.
271
272 `update' There must be a record matching REC: write REC in its
273 place.
274 """
a2916c06
MW
275
276 ## The main update function.
277 def doit():
278
279 ## Make sure we preserve the file permissions, and in particular don't
280 ## allow a window during which the new file has looser permissions than
281 ## the old one.
282 st = OS.stat(me._file)
283 tmp = me._file + '.new'
284 fd = OS.open(tmp, OS.O_WRONLY | OS.O_CREAT | OS.O_EXCL, st.st_mode)
285
286 ## This is the fiddly bit.
287 lose = True
288 try:
289
290 ## Copy the old file to the new one, changing the user's record if
291 ## and when we encounter it.
612419ac 292 found = False
a2916c06
MW
293 with OS.fdopen(fd, 'w') as f_out:
294 with open(me._file) as f_in:
295 for line in f_in:
296 r = me._parse(line)
297 if r.user != rec.user:
298 f_out.write(line)
612419ac
MW
299 elif op == 'create':
300 raise U.ExpectedError, \
301 (500, "Record for `%s' already exists" % rec.user)
a2916c06 302 else:
612419ac
MW
303 found = True
304 if op != 'remove': f_out.write(rec._format())
305 if found:
306 pass
307 elif op == 'create':
308 f_out.write(rec._format())
309 else:
310 raise U.ExpectedError, \
311 (500, "Record for `%s' not found" % rec.user)
a2916c06
MW
312
313 ## Update the permissions on the new file. Don't try to fix the
314 ## ownership (we shouldn't be running as root) or the group (the
315 ## parent directory should have the right permissions already).
316 OS.chmod(tmp, st.st_mode)
317 OS.rename(tmp, me._file)
318 lose = False
319 except OSError, e:
320 ## I suppose that system errors are to be expected at this point.
321 raise U.ExpectedError, \
322 (500, "Failed to update `%s': %s" % (me._file, e))
323 finally:
324 ## Don't try to delete the new file if we succeeded: it might belong
325 ## to another instance of us.
326 if lose:
327 try: OS.unlink(tmp)
328 except: pass
329
74b87214 330 ## If there's a lockfile, then acquire it around the meat of this
a2916c06
MW
331 ## function; otherwise just do the job.
332 if me._lock is None:
333 doit()
334 else:
335 with U.lockfile(OS.path.join(CFG.LOCKDIR, me._lock), 5):
336 doit()
337
338 def _parse(me, line):
339 """Convenience function for constructing a record."""
340 return FlatFileRecord(line, me._delim, me._fmap, backend = me)
341
612419ac
MW
342 def _update(me, rec):
343 """Update the record REC in the file."""
344 me._rewrite('update', rec)
345
82d4f64b
MW
346 def _remove(me, rec):
347 """Update the record REC in the file."""
348 me._rewrite('remove', rec)
349
a2916c06
MW
350CONF.export('FlatFileBackend')
351
352###--------------------------------------------------------------------------
353### SQL databases.
354
355class DatabaseBackend (object):
356 """
357 Password storage in a SQL database table.
358
359 We assume that there's a single table mapping user names to (hashed)
360 passwords: we won't try anything complicated involving joins.
361
362 We need to know a database module MODNAME and arguments MODARGS to pass to
363 the `connect' function. We also need to know the TABLE to search, and the
364 USER and PASSWD field names. Additional field names can be passed to the
365 constructor: these will be read from the database and attached as
366 attributes `f_NAME' to the record returned by `lookup'. Changes to these
367 attributes are currently not propagated back to the database.
368 """
369
370 def __init__(me, modname, modargs, table, user, passwd, *fields):
371 """
372 Create a database backend object. See the class docstring for details.
373 """
374 me._table = table
375 me._user = user
376 me._passwd = passwd
377 me._fields = list(fields)
378
379 ## We don't connect immediately. That would be really bad if we had lots
380 ## of database backends running at a time, because we probably only want
381 ## to use one.
382 me._db = None
383 me._modname = modname
384 me._modargs = modargs
385
386 def _connect(me):
387 """Set up the lazy connection to the database."""
388 if me._db is None:
389 me._db = U.SimpleDBConnection(me._modname, me._modargs)
390
391 def lookup(me, user):
392 """Return the record for the named USER."""
393 me._connect()
394 me._db.execute("SELECT %s FROM %s WHERE %s = $user" %
395 (', '.join([me._passwd] + me._fields),
396 me._table, me._user),
397 user = user)
398 row = me._db.fetchone()
399 if row is None: raise UnknownUser, user
400 passwd = row[0]
401 rec = TrivialRecord(backend = me, user = user, passwd = passwd)
402 for f, v in zip(me._fields, row[1:]):
403 setattr(rec, 'f_' + f, v)
404 return rec
405
82d4f64b
MW
406 def create(me, user, passwd, args):
407 """
408 Create a new record for the named USER.
409
410 The new record has the given PASSWD, and other fields are set from ARGS.
411 Those ARGS of the form `KEY=VALUE' set the appropriately named fields (as
412 set up by the constructor); other ARGS fill in unset fields, left to
413 right, in the order given to the constructor.
414 """
415
416 tags = ['user', 'passwd'] + \
417 ['t_%d' % 0 for i in xrange(len(me._fields))]
418 f = fill_in_fields(0, 1, list(I.izip(me._fields, I.count(2))),
419 user, passwd, args)
420 me._connect()
421 with me._db:
422 me._db.execute("INSERT INTO %s (%s) VALUES (%s)" %
423 (me._table,
424 ', '.join([me._user, me._passwd] + me._fields),
425 ', '.join(['$%s' % t for t in tags])),
426 **dict(I.izip(tags, f)))
427
428 def _remove(me, rec):
429 """Remove the record REC from the database."""
430 me._connect()
431 with me._db:
432 me._db.execute("DELETE FROM %s WHERE %s = $user" %
433 (me._table, me._user),
434 user = rec.user)
435
a2916c06
MW
436 def _update(me, rec):
437 """Update the record REC in the database."""
438 me._connect()
439 with me._db:
440 me._db.execute(
441 "UPDATE %s SET %s = $passwd WHERE %s = $user" % (
442 me._table, me._passwd, me._user),
443 user = rec.user, passwd = rec.passwd)
444
445CONF.export('DatabaseBackend')
446
447###----- That's all, folks --------------------------------------------------