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 | ||
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 | |
1f8350d2 | 139 | return me._delim.join(fields) + '\n' |
a2916c06 MW |
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()) | |
a2916c06 MW |
211 | |
212 | ## Update the permissions on the new file. Don't try to fix the | |
213 | ## ownership (we shouldn't be running as root) or the group (the | |
214 | ## parent directory should have the right permissions already). | |
215 | OS.chmod(tmp, st.st_mode) | |
216 | OS.rename(tmp, me._file) | |
217 | lose = False | |
218 | except OSError, e: | |
219 | ## I suppose that system errors are to be expected at this point. | |
220 | raise U.ExpectedError, \ | |
221 | (500, "Failed to update `%s': %s" % (me._file, e)) | |
222 | finally: | |
223 | ## Don't try to delete the new file if we succeeded: it might belong | |
224 | ## to another instance of us. | |
225 | if lose: | |
226 | try: OS.unlink(tmp) | |
227 | except: pass | |
228 | ||
74b87214 | 229 | ## If there's a lockfile, then acquire it around the meat of this |
a2916c06 MW |
230 | ## function; otherwise just do the job. |
231 | if me._lock is None: | |
232 | doit() | |
233 | else: | |
234 | with U.lockfile(OS.path.join(CFG.LOCKDIR, me._lock), 5): | |
235 | doit() | |
236 | ||
237 | def _parse(me, line): | |
238 | """Convenience function for constructing a record.""" | |
239 | return FlatFileRecord(line, me._delim, me._fmap, backend = me) | |
240 | ||
241 | CONF.export('FlatFileBackend') | |
242 | ||
243 | ###-------------------------------------------------------------------------- | |
244 | ### SQL databases. | |
245 | ||
246 | class DatabaseBackend (object): | |
247 | """ | |
248 | Password storage in a SQL database table. | |
249 | ||
250 | We assume that there's a single table mapping user names to (hashed) | |
251 | passwords: we won't try anything complicated involving joins. | |
252 | ||
253 | We need to know a database module MODNAME and arguments MODARGS to pass to | |
254 | the `connect' function. We also need to know the TABLE to search, and the | |
255 | USER and PASSWD field names. Additional field names can be passed to the | |
256 | constructor: these will be read from the database and attached as | |
257 | attributes `f_NAME' to the record returned by `lookup'. Changes to these | |
258 | attributes are currently not propagated back to the database. | |
259 | """ | |
260 | ||
261 | def __init__(me, modname, modargs, table, user, passwd, *fields): | |
262 | """ | |
263 | Create a database backend object. See the class docstring for details. | |
264 | """ | |
265 | me._table = table | |
266 | me._user = user | |
267 | me._passwd = passwd | |
268 | me._fields = list(fields) | |
269 | ||
270 | ## We don't connect immediately. That would be really bad if we had lots | |
271 | ## of database backends running at a time, because we probably only want | |
272 | ## to use one. | |
273 | me._db = None | |
274 | me._modname = modname | |
275 | me._modargs = modargs | |
276 | ||
277 | def _connect(me): | |
278 | """Set up the lazy connection to the database.""" | |
279 | if me._db is None: | |
280 | me._db = U.SimpleDBConnection(me._modname, me._modargs) | |
281 | ||
282 | def lookup(me, user): | |
283 | """Return the record for the named USER.""" | |
284 | me._connect() | |
285 | me._db.execute("SELECT %s FROM %s WHERE %s = $user" % | |
286 | (', '.join([me._passwd] + me._fields), | |
287 | me._table, me._user), | |
288 | user = user) | |
289 | row = me._db.fetchone() | |
290 | if row is None: raise UnknownUser, user | |
291 | passwd = row[0] | |
292 | rec = TrivialRecord(backend = me, user = user, passwd = passwd) | |
293 | for f, v in zip(me._fields, row[1:]): | |
294 | setattr(rec, 'f_' + f, v) | |
295 | return rec | |
296 | ||
297 | def _update(me, rec): | |
298 | """Update the record REC in the database.""" | |
299 | me._connect() | |
300 | with me._db: | |
301 | me._db.execute( | |
302 | "UPDATE %s SET %s = $passwd WHERE %s = $user" % ( | |
303 | me._table, me._passwd, me._user), | |
304 | user = rec.user, passwd = rec.passwd) | |
305 | ||
306 | CONF.export('DatabaseBackend') | |
307 | ||
308 | ###----- That's all, folks -------------------------------------------------- |