chiark / gitweb /
Put the user's name in post-authentication requests.
[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 config as CONF; CFG = CONF.CFG
35 import hash as H
36 import 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
63 UnknownUser = B.UnknownUser
64
65 class 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
75 class 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
88 class 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
129 class 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
163 CONF.export('LocalService')
164
165 ###--------------------------------------------------------------------------
166 ### Remote services.
167
168 class 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
254 class 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
302 CONF.export('SSHRemoteService')
303
304 class 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
354 CONF.export('CommandRemoteService')
355
356 ###--------------------------------------------------------------------------
357 ### Services registry.
358
359 ## The registry of services.
360 SERVICES = {}
361 CONF.export('SERVICES')
362
363 ## Set some default configuration.
364 CONF.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
374 def 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 --------------------------------------------------