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 MW |
43 | |
44 | ###-------------------------------------------------------------------------- | |
82d4f64b MW |
45 | ### Utilities. |
46 | ||
47 | def 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 | ||
121 | class 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 | ||
127 | class 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 | |
141 | class 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 | ||
155 | class 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 | |
202 | class 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 |
365 | CONF.export('FlatFileBackend') |
366 | ||
367 | ###-------------------------------------------------------------------------- | |
368 | ### SQL databases. | |
369 | ||
370 | class 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 | ||
460 | CONF.export('DatabaseBackend') | |
461 | ||
462 | ###----- That's all, folks -------------------------------------------------- |