5 ### (c) 2013 Mark Wooding
8 ###----- Licensing notice ---------------------------------------------------
10 ### This file is part of Chopwood: a password-changing service.
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.
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.
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/>.
26 from __future__
import with_statement
29 import os
as OS
; ENV
= OS
.environ
31 import config
as CONF
; CFG
= CONF
.CFG
34 ###--------------------------------------------------------------------------
35 ### Relevant configuration.
39 ## A directory in which we can create lockfiles.
40 LOCKDIR
= OS
.path
.join(ENV
['HOME'], 'var', 'lock', 'chpwd'))
42 ###--------------------------------------------------------------------------
45 def fill_in_fields(fno_user
, fno_passwd
, fno_map
, user
, passwd
, args
):
47 Return a vector of filled-in fields.
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'.
56 ## Prepare the result vector, and set up some data structures.
59 rmap
= map(int, xrange(n
))
61 if fno_user
>= n
or fno_passwd
>= n
: ok
= False
67 raise U
.ExpectedError
, \
68 (500, "Fields specified aren't contiguous")
70 ## Prepare the new record's fields.
73 f
[fno_passwd
] = passwd
77 k
, v
= a
.split('=', 1)
79 except KeyError: raise U
.ExpectedError
, (400, "Unknown field `%s'" % k
)
82 if f
[i
] is None: break
84 raise U
.ExpectedError
, (500, "All fields already populated")
87 raise U
.ExpectedError
, (400, "Field %s is already set" % rmap
[i
])
90 ## Check that the vector of fields is properly set up.
93 raise U
.ExpectedError
, (500, "Field %s is unset" % rmap
[i
])
98 ###--------------------------------------------------------------------------
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.
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
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.
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
)
125 class BasicRecord (object):
127 A handy base class for record classes.
129 Keep track of the backend in `_be', and call its `_update' method to write
132 def __init__(me
, backend
):
139 class TrivialRecord (BasicRecord
):
141 A trivial record which simply remembers `user' and `passwd' attributes.
143 Additional attributes can be set on the object if this is convenient.
145 def __init__(me
, user
, passwd
, *args
, **kw
):
146 super(TrivialRecord
, me
).__init__(*args
, **kw
)
150 ###--------------------------------------------------------------------------
153 class FlatFileRecord (BasicRecord
):
155 A record from a flat-file database (like a passwd(5) file).
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.
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).
166 def __init__(me
, line
, delim
, fmap
, *args
, **kw
):
168 Initialize the record, splitting the LINE into fields separated by DELIM,
169 and setting attributes under control of FMAP.
171 super(FlatFileRecord
, me
).__init__(*args
, **kw
)
172 line
= line
.rstrip('\n')
173 fields
= line
.split(delim
)
177 for k
, v
in fmap
.iteritems():
178 setattr(me
, k
, fields
[v
])
182 Format the record as a line of text.
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.
189 for k
, v
in me
._fmap
.iteritems():
191 for badch
, what
in [(me
._delim
, "delimiter `%s'" % me
._delim
),
192 ('\n', 'newline character'),
193 ('\0', 'null character')]:
195 raise U
.ExpectedError
, \
196 (500, "New `%s' field contains %s" %
(k
, what
))
198 return me
._delim
.join(fields
) + '\n'
200 class FlatFileBackend (object):
202 Password storage in a flat passwd(5)-style file.
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.
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.
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.
221 def __init__(me
, file, lock
= None,
222 delim
= ':', user
= 0, passwd
= 1, **fields
):
224 Construct a new flat-file backend object. See the class documentation
230 fmap
= dict(user
= user
, passwd
= passwd
)
231 for k
, v
in fields
.iteritems(): fmap
['f_' + k
] = v
234 def lookup(me
, user
):
235 """Return the record for the named USER."""
236 with
open(me
._file
) as f
:
238 rec
= me
._parse(line
)
241 raise UnknownUser
, user
243 def create(me
, user
, passwd
, args
):
245 Create a new record for the USER.
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
253 f
= fill_in_fields(me
._fmap
['user'], me
._fmap
['passwd'],
255 for k
, i
in me
._fmap
.iteritems()
256 if k
.startswith('f_')],
258 r
= FlatFileRecord(':'.join(f
), me
._delim
, me
._fmap
, backend
= me
)
259 me
._rewrite('create', r
)
261 def _rewrite(me
, op
, rec
):
263 Rewrite the file, according to OP.
265 The OP may be one of the following.
267 `create' There must not be a record matching REC; add a new
270 `remove' There must be a record matching REC: remove it.
272 `update' There must be a record matching REC: write REC in its
276 ## The main update function.
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
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
)
286 ## This is the fiddly bit.
290 ## Copy the old file to the new one, changing the user's record if
291 ## and when we encounter it.
293 with OS
.fdopen(fd
, 'w') as f_out
:
294 with
open(me
._file
) as f_in
:
297 if r
.user
!= rec
.user
:
300 raise U
.ExpectedError
, \
301 (500, "Record for `%s' already exists" % rec
.user
)
304 if op
!= 'remove': f_out
.write(rec
._format())
308 f_out
.write(rec
._format())
310 raise U
.ExpectedError
, \
311 (500, "Record for `%s' not found" % rec
.user
)
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
)
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
))
324 ## Don't try to delete the new file if we succeeded: it might belong
325 ## to another instance of us.
330 ## If there's a lockfile, then acquire it around the meat of this
331 ## function; otherwise just do the job.
335 with U
.lockfile(OS
.path
.join(CFG
.LOCKDIR
, me
._lock
), 5):
338 def _parse(me
, line
):
339 """Convenience function for constructing a record."""
340 return FlatFileRecord(line
, me
._delim
, me
._fmap
, backend
= me
)
342 def _update(me
, rec
):
343 """Update the record REC in the file."""
344 me
._rewrite('update', rec
)
346 def _remove(me
, rec
):
347 """Update the record REC in the file."""
348 me
._rewrite('remove', rec
)
350 CONF
.export('FlatFileBackend')
352 ###--------------------------------------------------------------------------
355 class DatabaseBackend (object):
357 Password storage in a SQL database table.
359 We assume that there's a single table mapping user names to (hashed)
360 passwords: we won't try anything complicated involving joins.
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.
370 def __init__(me
, modname
, modargs
, table
, user
, passwd
, *fields
):
372 Create a database backend object. See the class docstring for details.
377 me
._fields
= list(fields
)
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
383 me
._modname
= modname
384 me
._modargs
= modargs
387 """Set up the lazy connection to the database."""
389 me
._db
= U
.SimpleDBConnection(me
._modname
, me
._modargs
)
391 def lookup(me
, user
):
392 """Return the record for the named USER."""
394 me
._db
.execute("SELECT %s FROM %s WHERE %s = $user" %
395 (', '.join([me
._passwd
] + me
._fields
),
396 me
._table
, me
._user
),
398 row
= me
._db
.fetchone()
399 if row
is None: raise UnknownUser
, user
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
)
406 def create(me
, user
, passwd
, args
):
408 Create a new record for the named USER.
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.
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))),
422 me
._db
.execute("INSERT INTO %s (%s) VALUES (%s)" %
424 ', '.join([me
._user
, me
._passwd
] + me
._fields
),
425 ', '.join(['$%s' % t
for t
in tags
])),
426 **dict(I
.izip(tags
, f
)))
428 def _remove(me
, rec
):
429 """Remove the record REC from the database."""
432 me
._db
.execute("DELETE FROM %s WHERE %s = $user" %
433 (me
._table
, me
._user
),
436 def _update(me
, rec
):
437 """Update the record REC in the database."""
441 "UPDATE %s SET %s = $passwd WHERE %s = $user" %
(
442 me
._table
, me
._passwd
, me
._user
),
443 user
= rec
.user
, passwd
= rec
.passwd
)
445 CONF
.export('DatabaseBackend')
447 ###----- That's all, folks --------------------------------------------------