| 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 | |
| 28 | from auto import HOME |
| 29 | import errno as E |
| 30 | import itertools as I |
| 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. |
| 42 | LOCKDIR = OS.path.join(HOME, 'lock')) |
| 43 | |
| 44 | ###-------------------------------------------------------------------------- |
| 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 | ###-------------------------------------------------------------------------- |
| 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) |
| 138 | def remove(me): |
| 139 | me._be._remove(me) |
| 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 |
| 200 | return me._delim.join(fields) + '\n' |
| 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 |
| 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. |
| 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 | |
| 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) |
| 263 | r = FlatFileRecord(me._delim.join(f), me._delim, me._fmap, backend = me) |
| 264 | me._rewrite('create', r) |
| 265 | |
| 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 | """ |
| 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. |
| 297 | found = False |
| 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) |
| 304 | elif op == 'create': |
| 305 | raise U.ExpectedError, \ |
| 306 | (500, "Record for `%s' already exists" % rec.user) |
| 307 | else: |
| 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) |
| 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 | |
| 335 | ## If there's a lockfile, then acquire it around the meat of this |
| 336 | ## function; otherwise just do the job. |
| 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() |
| 352 | |
| 353 | def _parse(me, line): |
| 354 | """Convenience function for constructing a record.""" |
| 355 | return FlatFileRecord(line, me._delim, me._fmap, backend = me) |
| 356 | |
| 357 | def _update(me, rec): |
| 358 | """Update the record REC in the file.""" |
| 359 | me._rewrite('update', rec) |
| 360 | |
| 361 | def _remove(me, rec): |
| 362 | """Update the record REC in the file.""" |
| 363 | me._rewrite('remove', rec) |
| 364 | |
| 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 | |
| 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 | |
| 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 -------------------------------------------------- |