| 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 | import os as OS; ENV = OS.environ |
| 29 | |
| 30 | import config as CONF; CFG = CONF.CFG |
| 31 | import util as U |
| 32 | |
| 33 | ###-------------------------------------------------------------------------- |
| 34 | ### Relevant configuration. |
| 35 | |
| 36 | CONF.DEFAULTS.update( |
| 37 | |
| 38 | ## A directory in which we can create lockfiles. |
| 39 | LOCKDIR = OS.path.join(ENV['HOME'], 'var', 'lock', 'chpwd')) |
| 40 | |
| 41 | ###-------------------------------------------------------------------------- |
| 42 | ### Protocol. |
| 43 | ### |
| 44 | ### A password backend knows how to fetch and modify records in some password |
| 45 | ### database, e.g., a flat passwd(5)-style password file, or a table in some |
| 46 | ### proper grown-up SQL database. |
| 47 | ### |
| 48 | ### A backend's `lookup' method retrieves the record for a named user from |
| 49 | ### the database, returning it in a record object, or raises `UnknownUser'. |
| 50 | ### The record object maintains `user' (the user name, as supplied to |
| 51 | ### `lookup') and `passwd' (the encrypted password, in whatever form the |
| 52 | ### underlying database uses) attributes, and possibly others. The `passwd' |
| 53 | ### attribute (at least) may be modified by the caller. The record object |
| 54 | ### has a `write' method, which updates the corresponding record in the |
| 55 | ### database. |
| 56 | ### |
| 57 | ### The concrete record objects defined here inherit from `BasicRecord', |
| 58 | ### which keeps track of its parent backend, and implements `write' by |
| 59 | ### calling the backend's `_update' method. Some backends require that their |
| 60 | ### record objects implement additional private protocols. |
| 61 | |
| 62 | class UnknownUser (U.ExpectedError): |
| 63 | """The named user wasn't found in the database.""" |
| 64 | def __init__(me, user): |
| 65 | U.ExpectedError.__init__(me, 500, "Unknown user `%s'" % user) |
| 66 | me.user = user |
| 67 | |
| 68 | class BasicRecord (object): |
| 69 | """ |
| 70 | A handy base class for record classes. |
| 71 | |
| 72 | Keep track of the backend in `_be', and call its `_update' method to write |
| 73 | ourselves back. |
| 74 | """ |
| 75 | def __init__(me, backend): |
| 76 | me._be = backend |
| 77 | def write(me): |
| 78 | me._be._update(me) |
| 79 | |
| 80 | class TrivialRecord (BasicRecord): |
| 81 | """ |
| 82 | A trivial record which simply remembers `user' and `passwd' attributes. |
| 83 | |
| 84 | Additional attributes can be set on the object if this is convenient. |
| 85 | """ |
| 86 | def __init__(me, user, passwd, *args, **kw): |
| 87 | super(TrivialRecord, me).__init__(*args, **kw) |
| 88 | me.user = user |
| 89 | me.passwd = passwd |
| 90 | |
| 91 | ###-------------------------------------------------------------------------- |
| 92 | ### Flat files. |
| 93 | |
| 94 | class FlatFileRecord (BasicRecord): |
| 95 | """ |
| 96 | A record from a flat-file database (like a passwd(5) file). |
| 97 | |
| 98 | Such a file carries one record per line; each record is split into fields |
| 99 | by a delimiter character, specified by the DELIM constructor argument. |
| 100 | |
| 101 | The FMAP argument to the constructor maps names to field index numbers. |
| 102 | The standard `user' and `passwd' fields must be included in this map if the |
| 103 | object is to implement the protocol correctly (though the `FlatFileBackend' |
| 104 | is careful to do this). |
| 105 | """ |
| 106 | |
| 107 | def __init__(me, line, delim, fmap, *args, **kw): |
| 108 | """ |
| 109 | Initialize the record, splitting the LINE into fields separated by DELIM, |
| 110 | and setting attributes under control of FMAP. |
| 111 | """ |
| 112 | super(FlatFileRecord, me).__init__(*args, **kw) |
| 113 | line = line.rstrip('\n') |
| 114 | fields = line.split(delim) |
| 115 | me._delim = delim |
| 116 | me._fmap = fmap |
| 117 | me._raw = fields |
| 118 | for k, v in fmap.iteritems(): |
| 119 | setattr(me, k, fields[v]) |
| 120 | |
| 121 | def _format(me): |
| 122 | """ |
| 123 | Format the record as a line of text. |
| 124 | |
| 125 | The flat-file format is simple, but rather fragile with respect to |
| 126 | invalid characters, and often processed by substandard software, so be |
| 127 | careful not to allow bad characters into the file. |
| 128 | """ |
| 129 | fields = me._raw |
| 130 | for k, v in me._fmap.iteritems(): |
| 131 | val = getattr(me, k) |
| 132 | for badch, what in [(me._delim, "delimiter `%s'" % me._delim), |
| 133 | ('\n', 'newline character'), |
| 134 | ('\0', 'null character')]: |
| 135 | if badch in val: |
| 136 | raise U.ExpectedError, \ |
| 137 | (500, "New `%s' field contains %s" % (k, what)) |
| 138 | fields[v] = val |
| 139 | return me._delim.join(fields) |
| 140 | |
| 141 | class FlatFileBackend (object): |
| 142 | """ |
| 143 | Password storage in a flat passwd(5)-style file. |
| 144 | |
| 145 | The FILE constructor argument names the file. Such a file carries one |
| 146 | record per line; each record is split into fields by a delimiter character, |
| 147 | specified by the DELIM constructor argument. |
| 148 | |
| 149 | The file is updated by writing a new version alongside, as `FILE.new', and |
| 150 | renaming it over the old version. If a LOCK file is named then an |
| 151 | exclusive fcntl(2)-style lock is taken out on `LOCKDIR/LOCK' (creating the |
| 152 | file if necessary) during the update operation. Use of a lockfile is |
| 153 | strongly recommended. |
| 154 | |
| 155 | The DELIM constructor argument specifies the delimiter character used when |
| 156 | splitting lines into fields. The USER and PASSWD arguments give the field |
| 157 | numbers (starting from 0) for the user-name and hashed-password fields; |
| 158 | additional field names may be given using keyword arguments: the values of |
| 159 | these fields are exposed as attributes `f_NAME' on record objects. |
| 160 | """ |
| 161 | |
| 162 | def __init__(me, file, lock = None, |
| 163 | delim = ':', user = 0, passwd = 1, **fields): |
| 164 | """ |
| 165 | Construct a new flat-file backend object. See the class documentation |
| 166 | for details. |
| 167 | """ |
| 168 | me._lock = lock |
| 169 | me._file = file |
| 170 | me._delim = delim |
| 171 | fmap = dict(user = user, passwd = passwd) |
| 172 | for k, v in fields.iteritems(): fmap['f_' + k] = v |
| 173 | me._fmap = fmap |
| 174 | |
| 175 | def lookup(me, user): |
| 176 | """Return the record for the named USER.""" |
| 177 | with open(me._file) as f: |
| 178 | for line in f: |
| 179 | rec = me._parse(line) |
| 180 | if rec.user == user: |
| 181 | return rec |
| 182 | raise UnknownUser, user |
| 183 | |
| 184 | def _update(me, rec): |
| 185 | """Update the record REC in the file.""" |
| 186 | |
| 187 | ## The main update function. |
| 188 | def doit(): |
| 189 | |
| 190 | ## Make sure we preserve the file permissions, and in particular don't |
| 191 | ## allow a window during which the new file has looser permissions than |
| 192 | ## the old one. |
| 193 | st = OS.stat(me._file) |
| 194 | tmp = me._file + '.new' |
| 195 | fd = OS.open(tmp, OS.O_WRONLY | OS.O_CREAT | OS.O_EXCL, st.st_mode) |
| 196 | |
| 197 | ## This is the fiddly bit. |
| 198 | lose = True |
| 199 | try: |
| 200 | |
| 201 | ## Copy the old file to the new one, changing the user's record if |
| 202 | ## and when we encounter it. |
| 203 | with OS.fdopen(fd, 'w') as f_out: |
| 204 | with open(me._file) as f_in: |
| 205 | for line in f_in: |
| 206 | r = me._parse(line) |
| 207 | if r.user != rec.user: |
| 208 | f_out.write(line) |
| 209 | else: |
| 210 | f_out.write(rec._format()) |
| 211 | f_out.write('\n') |
| 212 | |
| 213 | ## Update the permissions on the new file. Don't try to fix the |
| 214 | ## ownership (we shouldn't be running as root) or the group (the |
| 215 | ## parent directory should have the right permissions already). |
| 216 | OS.chmod(tmp, st.st_mode) |
| 217 | OS.rename(tmp, me._file) |
| 218 | lose = False |
| 219 | except OSError, e: |
| 220 | ## I suppose that system errors are to be expected at this point. |
| 221 | raise U.ExpectedError, \ |
| 222 | (500, "Failed to update `%s': %s" % (me._file, e)) |
| 223 | finally: |
| 224 | ## Don't try to delete the new file if we succeeded: it might belong |
| 225 | ## to another instance of us. |
| 226 | if lose: |
| 227 | try: OS.unlink(tmp) |
| 228 | except: pass |
| 229 | |
| 230 | ## If there's a locekfile, then acquire it around the meat of this |
| 231 | ## function; otherwise just do the job. |
| 232 | if me._lock is None: |
| 233 | doit() |
| 234 | else: |
| 235 | with U.lockfile(OS.path.join(CFG.LOCKDIR, me._lock), 5): |
| 236 | doit() |
| 237 | |
| 238 | def _parse(me, line): |
| 239 | """Convenience function for constructing a record.""" |
| 240 | return FlatFileRecord(line, me._delim, me._fmap, backend = me) |
| 241 | |
| 242 | CONF.export('FlatFileBackend') |
| 243 | |
| 244 | ###-------------------------------------------------------------------------- |
| 245 | ### SQL databases. |
| 246 | |
| 247 | class DatabaseBackend (object): |
| 248 | """ |
| 249 | Password storage in a SQL database table. |
| 250 | |
| 251 | We assume that there's a single table mapping user names to (hashed) |
| 252 | passwords: we won't try anything complicated involving joins. |
| 253 | |
| 254 | We need to know a database module MODNAME and arguments MODARGS to pass to |
| 255 | the `connect' function. We also need to know the TABLE to search, and the |
| 256 | USER and PASSWD field names. Additional field names can be passed to the |
| 257 | constructor: these will be read from the database and attached as |
| 258 | attributes `f_NAME' to the record returned by `lookup'. Changes to these |
| 259 | attributes are currently not propagated back to the database. |
| 260 | """ |
| 261 | |
| 262 | def __init__(me, modname, modargs, table, user, passwd, *fields): |
| 263 | """ |
| 264 | Create a database backend object. See the class docstring for details. |
| 265 | """ |
| 266 | me._table = table |
| 267 | me._user = user |
| 268 | me._passwd = passwd |
| 269 | me._fields = list(fields) |
| 270 | |
| 271 | ## We don't connect immediately. That would be really bad if we had lots |
| 272 | ## of database backends running at a time, because we probably only want |
| 273 | ## to use one. |
| 274 | me._db = None |
| 275 | me._modname = modname |
| 276 | me._modargs = modargs |
| 277 | |
| 278 | def _connect(me): |
| 279 | """Set up the lazy connection to the database.""" |
| 280 | if me._db is None: |
| 281 | me._db = U.SimpleDBConnection(me._modname, me._modargs) |
| 282 | |
| 283 | def lookup(me, user): |
| 284 | """Return the record for the named USER.""" |
| 285 | me._connect() |
| 286 | me._db.execute("SELECT %s FROM %s WHERE %s = $user" % |
| 287 | (', '.join([me._passwd] + me._fields), |
| 288 | me._table, me._user), |
| 289 | user = user) |
| 290 | row = me._db.fetchone() |
| 291 | if row is None: raise UnknownUser, user |
| 292 | passwd = row[0] |
| 293 | rec = TrivialRecord(backend = me, user = user, passwd = passwd) |
| 294 | for f, v in zip(me._fields, row[1:]): |
| 295 | setattr(rec, 'f_' + f, v) |
| 296 | return rec |
| 297 | |
| 298 | def _update(me, rec): |
| 299 | """Update the record REC in the database.""" |
| 300 | me._connect() |
| 301 | with me._db: |
| 302 | me._db.execute( |
| 303 | "UPDATE %s SET %s = $passwd WHERE %s = $user" % ( |
| 304 | me._table, me._passwd, me._user), |
| 305 | user = rec.user, passwd = rec.passwd) |
| 306 | |
| 307 | CONF.export('DatabaseBackend') |
| 308 | |
| 309 | ###----- That's all, folks -------------------------------------------------- |