chiark / gitweb /
backend.py: Separate out the main work of `_update'.
[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
ac377b4f 34import cgi as CGI
a2916c06
MW
35import config as CONF; CFG = CONF.CFG
36import hash as H
37import 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
64UnknownUser = B.UnknownUser
65
66class 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
76class BasicService (object):
77 """
78 A simple base class for services.
79 """
80
788a729c 81 def __init__(me, friendly, name = None, *args, **kw):
a2916c06 82 super(BasicService, me).__init__(*args)
788a729c 83 me.name = name
a2916c06
MW
84 me.friendly = friendly
85 me.meta = kw
86
87###--------------------------------------------------------------------------
88### Local services.
89
90class 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
131class 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
165CONF.export('LocalService')
166
167###--------------------------------------------------------------------------
168### Remote services.
169
170class 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
c8d6d67b 186 def _run(me, cmd, input = None, state = None):
a2916c06
MW
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
c8d6d67b
MW
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
a2916c06
MW
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.
ac377b4f 209 with U.timeout(30, "waiting for remote service %s" % me._describe()):
c8d6d67b 210 proc = SUB.Popen(me._mkcmd(cmd, state),
a2916c06
MW
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
c8d6d67b 251 def _run_noout(me, cmd, input = None, state = None):
a2916c06 252 """Like `_run', but expect no output."""
c8d6d67b 253 for _ in me._run(cmd, input, state):
a2916c06
MW
254 raise U.ExpectedError, (500, 'Unexpected output from remote service')
255
256class 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 """
7789d380 284 super(SSHRemoteService, me).__init__(name = name, *args, **kw)
a2916c06 285 me._remote = remote
a2916c06
MW
286
287 def _describe(me):
288 """Description of the remote service."""
7789d380 289 return "`%s' via SSH to `%s'" % (me.name, me._remote),
a2916c06 290
7789d380 291 def _mkcmd(me, cmd, state):
a2916c06 292 """Format a command for SSH. Mainly escaping arguments."""
ac377b4f 293 return ['ssh', me._remote, ' '.join(map(CGI.urlencode, cmd))]
a2916c06
MW
294
295 def setpasswd(me, user, passwd):
296 """Service protocol: set the USER's password to PASSWD."""
7789d380 297 me._run_noout(['set', me.name, user], passwd + '\n')
a2916c06
MW
298
299 def clearpasswd(me, user):
300 """Service protocol: clear the USER's password."""
7789d380 301 me._run_noout(['clear', me.name, user])
a2916c06
MW
302
303CONF.export('SSHRemoteService')
304
305class CommandRemoteService (BasicRemoteService):
306 """
307 A remote service transported over a standard Unix command.
308
46eb5a38
MW
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:
a2916c06
MW
315
316 `%u' the user's name
317 `%%' a single `%' character
46eb5a38
MW
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.
a2916c06
MW
332 """
333
334 R_PAT = RX.compile('%(.)')
335
46eb5a38
MW
336 def __init__(me, default = ['ERR', '500', 'unimplemented command:'],
337 opmap = {}, *args, **kw):
74b87214 338 """Initialize the command remote service."""
a2916c06 339 super(CommandRemoteService, me).__init__(*args, **kw)
46eb5a38
MW
340 me._default = default
341 me._opmap = opmap
a2916c06 342
99968b29
MW
343 def _describe(me):
344 """Description of the remote service."""
345 return "`%s' command service (%s)" % (me.name, ' '.join(me._default))
346
c8d6d67b 347 def _subst(me, c, map):
a2916c06 348 """Return the substitution for the placeholder `%C'."""
c8d6d67b 349 return map.get(c, c)
a2916c06 350
c8d6d67b 351 def _mkcmd(me, cmd, map):
a2916c06 352 """Construct the command to be executed, by substituting placeholders."""
46eb5a38 353 if map is None: return cmd
c8d6d67b
MW
354 return [me.R_PAT.sub(lambda m: me._subst(m.group(1), map), arg)
355 for arg in cmd]
a2916c06 356
46eb5a38
MW
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
a2916c06
MW
375 def setpasswd(me, user, passwd):
376 """Service protocol: set the USER's password to PASSWD."""
46eb5a38 377 me._dispatch(me._run_noout, 'set', [('u', user)], passwd + '\n')
a2916c06
MW
378
379 def clearpasswd(me, user):
380 """Service protocol: clear the USER's password."""
46eb5a38 381 me._dispatch(me._run_noout, 'clear', [('u', user)])
a2916c06
MW
382
383CONF.export('CommandRemoteService')
384
385###--------------------------------------------------------------------------
386### Services registry.
387
388## The registry of services.
389SERVICES = {}
390CONF.export('SERVICES')
391
392## Set some default configuration.
393CONF.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
403def 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')
788a729c
MW
410 for name, svc in SERVICES.iteritems():
411 if svc.name is None: svc.name = name
a2916c06
MW
412
413###----- That's all, folks --------------------------------------------------