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