chiark / gitweb /
chpwd: Set default config file from environment variable `CHPWD_CONFIG'.
[chopwood] / service.py
CommitLineData
a2916c06
MW
1### -*-python-*-
2###
3### Services
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
26from __future__ import with_statement
27
28import os as OS
29import re as RX
30import subprocess as SUB
31
32from auto import HOME
33import backend as B
34import config as CONF; CFG = CONF.CFG
35import hash as H
36import util as U
37
38###--------------------------------------------------------------------------
39### Protocol.
40###
41### A service is a thing for which a user might have an account, with a login
42### name and password. The service protocol is fairly straightforward: a
43### password can be set to a particular value using `setpasswd' (which
44### handles details of hashing and so on), or cleared (i.e., preventing
45### logins using a password) using `clearpasswd'. Services also present
46### `friendly' names, used by the user interface.
47###
48### A service may be local or remote. Local services are implemented in
49### terms of a backend and hashing scheme. Information about a particular
50### user of a service is maintained in an `account' object which keeps track
51### of the backend record and hashing scheme; the service protocol operations
52### are handed off to the account. Accounts provide additional protocol for
53### clients which are willing to restrict themselves to the use of local
54### services.
55###
56### A remote service doesn't have local knowledge of the password database:
57### instead, it simply sends commands corresponding to the service protocol
58### operations to some external service which is expected to act on them.
59### The implementation here uses SSH, and the remote end is expected to be
60### provided by another instance of `chpwd', but that needn't be the case:
61### the protocol is very simple.
62
63UnknownUser = B.UnknownUser
64
65class IncorrectPassword (Exception):
66 """
67 A failed password check is reported via an exception.
68
69 This is /not/ an `ExpectedError', since we anticipate that whoever called
70 `check' will have made their own arrangements to deal with the failure in
71 some more useful way.
72 """
73 pass
74
75class BasicService (object):
76 """
77 A simple base class for services.
78 """
79
80 def __init__(me, friendly, *args, **kw):
81 super(BasicService, me).__init__(*args)
82 me.friendly = friendly
83 me.meta = kw
84
85###--------------------------------------------------------------------------
86### Local services.
87
88class Account (object):
89 """
90 An account represents information about a user of a particular service.
91
92 From here, we can implement the service protocol operations, and also check
93 passwords.
94
95 Users are expected to acquire account objects via the `lookup' method of a
96 `LocalService' or similar.
97 """
98
99 def __init__(me, svc, rec):
100 """
101 Create a new account, for the service SVC, holding the user record REC.
102 """
103 me._svc = svc
104 me._rec = rec
105 me._hash = svc.hash
106
107 def check(me, passwd):
108 """
109 Check the password PASSWD against the information we have. If the
110 password is correct, return normally; otherwise, raise
111 `IncorrectPassword'.
112 """
113 if not me._hash.check(me._rec, me._rec.passwd, passwd):
114 raise IncorrectPassword
115
116 def clearpasswd(me):
117 """Service protocol: clear the user's password."""
118 if me._hash.NULL is None:
119 raise U.ExpectedError, (400, "Can't clear this password")
120 me._rec.passwd = me._hash.NULL
121 me._rec.write()
122
123 def setpasswd(me, passwd):
124 """Service protocol: set the user's password to PASSWD."""
125 passwd = me._hash.hash(me._rec, passwd)
126 me._rec.passwd = passwd
127 me._rec.write()
128
129class LocalService (BasicService):
130 """
131 A local service has immediate knowledge of a hashing scheme and a password
132 storage backend. (Knowing connection details for a remote database server
133 is enough to qualify for being a `local' service. The important bit is
134 that the hashed passwords are exposed to us.)
135
136 The service protocol is implemented via an `Account', acquired through the
137 `find' method. Mainly for the benefit of the `Account' class, the
138 service's hashing scheme is exposed in the `hash' attribute.
139 """
140
141 def __init__(me, backend, hash, *args, **kw):
142 """
143 Create a new local service with a FRIENDLY name, using the given BACKEND
144 and HASH scheme.
145 """
146 super(LocalService, me).__init__(*args, **kw)
147 me._be = backend
148 me.hash = hash
149
150 def find(me, user):
151 """Find the named USER, returning an `Account' object."""
152 rec = me._be.lookup(user)
153 return Account(me, rec)
154
155 def setpasswd(me, user, passwd):
156 """Service protcol: set USER's password to PASSWD."""
157 me.find(user).setpasswd(passwd)
158
159 def clearpasswd(me, user):
160 """Service protocol: clear USER's password, preventing logins."""
161 me.find(user).clearpasswd()
162
163CONF.export('LocalService')
164
165###--------------------------------------------------------------------------
166### Remote services.
167
168class BasicRemoteService (BasicService):
169 """
170 A remote service transmits the simple service protocol operations to some
171 remote system, which presumably is better able to implement them than we
172 are. This is useful if, for example, the password file isn't available to
173 us, or we don't have (or can't be allowed to have) access to the database
174 tables containing password hashes, or must synchronize updates with some
175 remote process. It can also be useful to integrate with services which
176 don't present a conventional password file.
177
178 This class provides common machinery for communicating with various kinds
179 of remote service. Specific subclasses are provided for transporting
180 requests through SSH and GNU Userv; others can be added easily in local
181 configuration.
182 """
183
184 def _run(me, cmd, input = None):
185 """
186 This is the core of the remote service machinery. It issues a command
187 and parses the response. It will generate strings of informational
188 output from the command; error responses cause appropriate exceptions to
189 be raised.
190
191 The command is determined by passing the CMD argument to the `_mkcmd'
192 method, which a subclass must implement; it should return a list of
193 command-line arguments suitable for `subprocess.Popen'. The INPUT is a
194 string to make available on the command's stdin; if None, then no input
195 is provided to the command. The `_describe' method must provide a
196 description of the remote service for use in timeout messages.
197
198 We expect output on stdout in a simple line-based format. The first
199 whitespace-separated token on each line is a type code: `OK' means the
200 command completed successfully; `INFO' means the rest of the line is some
201 useful (and expected) information; and `ERR' means an error occurred: the
202 next token is an HTTP integer status code, and the remainder is a
203 human-readable message.
204 """
205
206 ## Run the command and collect its output and status.
207 with timeout(30, "waiting for remote service %s" % me._describe()):
208 proc = SUB.Popen(me._mkcmd(cmd),
209 stdin = input is not None and SUB.PIPE or None,
210 stdout = SUB.PIPE, stderr = SUB.PIPE)
211 out, err = proc.communicate(input)
212 st = proc.wait()
213
214 ## If the program failed then report this: it obviously didn't work
215 ## properly.
216 if st or err:
217 raise U.ExpectedError, (
218 500, 'Remote service error: %r (rc = %d)' % (err, st))
219
220 ## Split a word off the front of a string; return the word and the
221 ## remaining string.
222 def nextword(line):
223 ww = line.split(None, 1)
224 n = len(ww)
225 if not n: return None
226 elif n == 1: return ww[0], ''
227 else: return ww
228
229 ## Work through the lines, parsing them.
230 win = False
231 for line in out.splitlines():
232 type, rest = nextword(line)
233 if type == 'ERR':
234 code, msg = nextword(rest)
235 raise U.ExpectedError, (int(code), msg)
236 elif type == 'INFO':
237 yield rest
238 elif type == 'OK':
239 win = True
240 else:
241 raise U.ExpectedError, \
242 (500, 'Incomprehensible reply from remote service: %r' % line)
243
244 ## If we didn't get any kind of verdict then something weird has
245 ## happened.
246 if not win:
247 raise U.ExpectedError, (500, 'No reply from remote service')
248
249 def _run_noout(me, cmd, input = None):
250 """Like `_run', but expect no output."""
251 for _ in me._run(cmd, input):
252 raise U.ExpectedError, (500, 'Unexpected output from remote service')
253
254class SSHRemoteService (BasicRemoteService):
255 """
256 A remote service transported over SSH.
257
258 The remote service is given commands of the form
259
260 `set SERVICE USER'
261 Set USER's password for SERVICE to the password provided on the next
262 line of standard input.
263
264 `clear SERVICE USER'
265 Clear the USER's password for SERVICE.
266
267 Arguments are form-url-encoded, since SSH doesn't preserve token boundaries
268 in its argument list.
269
270 It is expected that the remote user has an `.ssh/authorized_keys' file
271 entry for us specifying a program to be run; the above commands will be
272 left available to this program in the environment variable
273 `SSH_ORIGINAL_COMMAND'.
274 """
275
276 def __init__(me, remote, name, *args, **kw):
277 """
278 Initialize an SSH remote service, contacting the SSH user REMOTE
279 (probably of the form `LOGIN@HOSTNAME') and referring to the service
280 NAME.
281 """
282 super(SSHRemoteService, me).__init__(*args, **kw)
283 me._remote = remote
284 me._name = name
285
286 def _describe(me):
287 """Description of the remote service."""
288 return "`%s' via SSH to `%s'" % (me._name, me._remote),
289
290 def _mkcmd(me, cmd):
291 """Format a command for SSH. Mainly escaping arguments."""
292 return ['ssh', me._remote, ' '.join(map(urlencode, cmd))]
293
294 def setpasswd(me, user, passwd):
295 """Service protocol: set the USER's password to PASSWD."""
296 me._run_noout(['set', me._name, user], passwd + '\n')
297
298 def clearpasswd(me, user):
299 """Service protocol: clear the USER's password."""
300 me._run_noout(['clear', me._name, user])
301
302CONF.export('SSHRemoteService')
303
304class CommandRemoteService (BasicRemoteService):
305 """
306 A remote service transported over a standard Unix command.
307
308 This is left rather generic. We need to know some command lists SET and
309 CLEAR containing the relevant service names and arguments. These are
310 simply executed, after simple placeholder substitution.
311
312 The SET command should read a password as its first line on stdin, and set
313 that as the user's new password. The CLEAR command should simply prevent
314 the user from logging in with a password. On success, the commands should
315 print a line `OK' to standard output, and on any kind of anticipated
316 failure, they should print `ERR' followed by an HTTP status code and a
317 message; in either case, the program should exit with status zero. In
318 disastrous cases, it's acceptable to print an error message to stderr
319 and/or exit with a nonzero status.
320
321 The placeholders are as follows.
322
323 `%u' the user's name
324 `%%' a single `%' character
325 """
326
327 R_PAT = RX.compile('%(.)')
328
329 def __init__(me, set, clear, *args, **kw):
330 """
331 Initialize the command remote service.
332 """
333 super(CommandRemoteService, me).__init__(*args, **kw)
334 me._set = set
335 me._clear = clear
336 me._map = dict(u = user)
337
338 def _subst(me, c):
339 """Return the substitution for the placeholder `%C'."""
340 return me._map.get(c, c)
341
342 def _mkcmd(me, cmd):
343 """Construct the command to be executed, by substituting placeholders."""
344 return [me.R_PAT.sub(lambda m: me._subst(m.group(1))) for arg in cmd]
345
346 def setpasswd(me, user, passwd):
347 """Service protocol: set the USER's password to PASSWD."""
348 me._run_noout(me._set, passwd + '\n')
349
350 def clearpasswd(me, user):
351 """Service protocol: clear the USER's password."""
352 me._run_noout(me._clear)
353
354CONF.export('CommandRemoteService')
355
356###--------------------------------------------------------------------------
357### Services registry.
358
359## The registry of services.
360SERVICES = {}
361CONF.export('SERVICES')
362
363## Set some default configuration.
364CONF.DEFAULTS.update(
365
366 ## The master database, as a pair (MODNAME, MODARGS).
367 DB = ('sqlite3', [OS.path.join(HOME, 'chpwd.db')]),
368
369 ## The hash to use for our master password database.
370 HASH = H.CryptHash('md5'))
371
372## Post-configuration hook: add the master service.
373@CONF.hook
374def add_master_service():
375 dbmod, dbargs = CFG.DB
376 SERVICES['master'] = \
377 LocalService(B.DatabaseBackend(dbmod, dbargs,
378 'users', 'user', 'passwd'),
379 CFG.HASH,
380 friendly = 'Password changing service')
381
382###----- That's all, folks --------------------------------------------------