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