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