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 | ||
82d4f64b | 28 | import itertools as I |
a2916c06 MW |
29 | import os as OS; ENV = OS.environ |
30 | ||
31 | import config as CONF; CFG = CONF.CFG | |
32 | import util as U | |
33 | ||
34 | ###-------------------------------------------------------------------------- | |
35 | ### Relevant configuration. | |
36 | ||
37 | CONF.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 | ||
45 | def 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 | ||
119 | class 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 | ||
125 | class 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 | |
139 | class 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 | ||
153 | class 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 | |
200 | class 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 |
350 | CONF.export('FlatFileBackend') |
351 | ||
352 | ###-------------------------------------------------------------------------- | |
353 | ### SQL databases. | |
354 | ||
355 | class 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 | ||
445 | CONF.export('DatabaseBackend') | |
446 | ||
447 | ###----- That's all, folks -------------------------------------------------- |