5 ### (c) 2013 Mark Wooding
8 ###----- Licensing notice ---------------------------------------------------
10 ### This file is part of Chopwood: a password-changing service.
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.
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.
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/>.
26 from __future__ import with_statement
30 import subprocess as SUB
35 import config as CONF; CFG = CONF.CFG
39 ###--------------------------------------------------------------------------
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: there
44 ### are methods corresponding to the various low-level operations which can
45 ### be performed on services. Services also present `friendly' names, used
46 ### by the user interface.
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
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.
63 UnknownUser = B.UnknownUser
65 class IncorrectPassword (Exception):
67 A failed password check is reported via an exception.
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
75 class BasicService (object):
77 A simple base class for services.
79 The `manage_pwent_p' flag indicates whether administration commands should
80 attempt to add or remove password entries in the corresponding database
81 when users are added or removed.
84 def __init__(me, friendly, name = None, manage_pwent_p = True,
86 super(BasicService, me).__init__(*args)
88 me.friendly = friendly
89 me.manage_pwent_p = manage_pwent_p
92 ###--------------------------------------------------------------------------
95 class Account (object):
97 An account represents information about a user of a particular service.
99 From here, we can implement the service protocol operations, and also check
102 Users are expected to acquire account objects via the `lookup' method of a
103 `LocalService' or similar.
106 def __init__(me, svc, rec):
108 Create a new account, for the service SVC, holding the user record REC.
114 def check(me, passwd):
116 Check the password PASSWD against the information we have. If the
117 password is correct, return normally; otherwise, raise
120 if not me._hash.check(me._rec, me._rec.passwd, passwd):
121 raise IncorrectPassword
124 """Service protocol: clear the user's password."""
125 if me._hash.NULL is None:
126 raise U.ExpectedError, (400, "Can't clear this password")
127 me._rec.passwd = me._hash.NULL
130 def setpasswd(me, passwd):
131 """Service protocol: set the user's password to PASSWD."""
132 passwd = me._hash.hash(me._rec, passwd)
133 me._rec.passwd = passwd
137 """Service protocol: remove the user's password entry."""
140 class LocalService (BasicService):
142 A local service has immediate knowledge of a hashing scheme and a password
143 storage backend. (Knowing connection details for a remote database server
144 is enough to qualify for being a `local' service. The important bit is
145 that the hashed passwords are exposed to us.)
147 The service protocol is implemented via an `Account', acquired through the
148 `find' method. Mainly for the benefit of the `Account' class, the
149 service's hashing scheme is exposed in the `hash' attribute.
152 def __init__(me, backend, hash, *args, **kw):
154 Create a new local service with a FRIENDLY name, using the given BACKEND
157 super(LocalService, me).__init__(*args, **kw)
162 """Find the named USER, returning an `Account' object."""
163 rec = me._be.lookup(user)
164 return Account(me, rec)
166 def setpasswd(me, user, passwd):
167 """Service protcol: set USER's password to PASSWD."""
168 me.find(user).setpasswd(passwd)
170 def clearpasswd(me, user):
171 """Service protocol: clear USER's password, preventing logins."""
172 me.find(user).clearpasswd()
174 def mkpwent(me, user, passwd, fields):
175 """Service protocol: create a record for USER."""
176 if me.hash.NULL is not None: passwd = me.hash.NULL
177 me._be.create(user, passwd, fields)
179 def rmpwent(me, user):
180 """Service protocol: delete the record for USER."""
181 me.find(user).remove()
183 CONF.export('LocalService')
185 ###--------------------------------------------------------------------------
188 class BasicRemoteService (BasicService):
190 A remote service transmits the simple service protocol operations to some
191 remote system, which presumably is better able to implement them than we
192 are. This is useful if, for example, the password file isn't available to
193 us, or we don't have (or can't be allowed to have) access to the database
194 tables containing password hashes, or must synchronize updates with some
195 remote process. It can also be useful to integrate with services which
196 don't present a conventional password file.
198 This class provides common machinery for communicating with various kinds
199 of remote service. Specific subclasses are provided for transporting
200 requests through SSH and GNU Userv; others can be added easily in local
204 def _run(me, cmd, input = None, state = None):
206 This is the core of the remote service machinery. It issues a command
207 and parses the response. It will generate strings of informational
208 output from the command; error responses cause appropriate exceptions to
211 The command is determined by passing the CMD and STATE arguments to the
212 `_mkcmd' method, which a subclass must implement; it should return a list
213 of command-line arguments suitable for `subprocess.Popen'. The INPUT is
214 a string to make available on the command's stdin; if None, then no input
215 is provided to the command. The `_describe' method must provide a
216 description of the remote service for use in timeout messages.
218 We expect output on stdout in a simple line-based format. The first
219 whitespace-separated token on each line is a type code: `OK' means the
220 command completed successfully; `INFO' means the rest of the line is some
221 useful (and expected) information; and `ERR' means an error occurred: the
222 next token is an HTTP integer status code, and the remainder is a
223 human-readable message.
226 ## Run the command and collect its output and status.
227 with U.timeout(30, "waiting for remote service %s" % me._describe()):
228 proc = SUB.Popen(me._mkcmd(cmd, state),
229 stdin = input is not None and SUB.PIPE or None,
230 stdout = SUB.PIPE, stderr = SUB.PIPE)
231 out, err = proc.communicate(input)
234 ## If the program failed then report this: it obviously didn't work
237 raise U.ExpectedError, (
238 500, 'Remote service error: %r (rc = %d)' % (err, st))
240 ## Split a word off the front of a string; return the word and the
243 ww = line.split(None, 1)
245 if not n: return None
246 elif n == 1: return ww[0], ''
249 ## Work through the lines, parsing them.
251 for line in out.splitlines():
252 type, rest = nextword(line)
254 code, msg = nextword(rest)
255 raise U.ExpectedError, (int(code), msg)
261 raise U.ExpectedError, \
262 (500, 'Incomprehensible reply from remote service: %r' % line)
264 ## If we didn't get any kind of verdict then something weird has
267 raise U.ExpectedError, (500, 'No reply from remote service')
269 def _run_noout(me, cmd, input = None, state = None):
270 """Like `_run', but expect no output."""
271 for _ in me._run(cmd, input, state):
272 raise U.ExpectedError, (500, 'Unexpected output from remote service')
274 class SSHRemoteService (BasicRemoteService):
276 A remote service transported over SSH.
278 The remote service is given commands of the form
281 Set USER's password for SERVICE to the password provided on the next
282 line of standard input.
285 Clear the USER's password for SERVICE.
287 `mkpwent USER SERVICE [FIELDS ...]'
288 Install a record for USER in the SERVICE, supplying any other
289 necessary FIELDS in the appropriate format. The user's password is
290 provided on the next line of standard input.
292 `rmpwent USER SERVICE'
293 Remove USER's password record for SERVICE.
295 Arguments are form-url-encoded, since SSH doesn't preserve token boundaries
296 in its argument list.
298 It is expected that the remote user has an `.ssh/authorized_keys' file
299 entry for us specifying a program to be run; the above commands will be
300 left available to this program in the environment variable
301 `SSH_ORIGINAL_COMMAND'.
304 def __init__(me, remote, name, *args, **kw):
306 Initialize an SSH remote service, contacting the SSH user REMOTE
307 (probably of the form `LOGIN@HOSTNAME') and referring to the service
310 super(SSHRemoteService, me).__init__(name = name, *args, **kw)
314 """Description of the remote service."""
315 return "`%s' via SSH to `%s'" % (me.name, me._remote),
317 def _mkcmd(me, cmd, state):
318 """Format a command for SSH. Mainly escaping arguments."""
319 return ['ssh', me._remote, ' '.join(map(CGI.urlencode, cmd))]
321 def setpasswd(me, user, passwd):
322 """Service protocol: set the USER's password to PASSWD."""
323 me._run_noout(['set', me.name, user], passwd + '\n')
325 def clearpasswd(me, user):
326 """Service protocol: clear the USER's password."""
327 me._run_noout(['clear', me.name, user])
329 def mkpwent(me, user, passwd, fields):
330 """Service protocol: create a record for USER."""
331 me._run_noout(['mkpwent', user, me.name] + fields, passwd + '\n')
333 def rmpwent(me, user):
334 """Service protocol: delete the record for USER."""
335 me._run_noout(['rmpwent', user, me.name])
337 CONF.export('SSHRemoteService')
339 class CommandRemoteService (BasicRemoteService):
341 A remote service transported over a standard Unix command.
343 This is left rather generic. Two strategies are available (and can be
344 combined using appropriate configuration). A DEFAULT command list can be
345 specified, and will be invoked as `DEFAULT OP ARGS...', where OP ARGS form
346 a Chopwood remote command. Additionally, an OPMAP dictionary can be
347 provided, mapping OP names (remote command names) to command lists
348 containing `%' placeholders, as follows:
351 `%%' a single `%' character
353 On success, the commands should print a line `OK' to standard output, and
354 on any kind of anticipated failure, they should print `ERR' followed by an
355 HTTP status code and a message; in either case, the program should exit
356 with status zero. In disastrous cases, it's acceptable to print an error
357 message to stderr and/or exit with a nonzero status.
359 Configuration hint: if you can only handle some subset of the available
360 commands, then your easy approach is to set commands for the operations you
361 can handle in the OPMAP, and set the DEFAULT to something like
363 ['echo', 'ERR', '500', 'unsupported command:']
365 to reject other commands.
368 R_PAT = RX.compile('%(.)')
371 default = ['echo', 'ERR', '500', 'unimplemented command:'],
372 opmap = {}, *args, **kw):
373 """Initialize the command remote service."""
374 super(CommandRemoteService, me).__init__(*args, **kw)
375 me._default = default
379 """Description of the remote service."""
380 return "`%s' command service (%s)" % (me.name, ' '.join(me._default))
382 def _subst(me, c, map):
383 """Return the substitution for the placeholder `%C'."""
386 def _mkcmd(me, cmd, map):
387 """Construct the command to be executed, by substituting placeholders."""
388 if map is None: return cmd
389 return [me.R_PAT.sub(lambda m: me._subst(m.group(1), map), arg)
392 def _dispatch(me, func, op, args, input = None):
394 Work out how to invoke a particular command.
396 Invoke FUNC, which works like `_run', with appropriate arguments. The OP
397 is a remote command name; ARGS is a sequence of (C, ARG) pairs, where C
398 is a placeholder character and ARG is a string value; INPUT is the text
399 to provide to the command on standard input.
404 cmd = me._default + [op] + [v for k, v in args]
408 return func(cmd, input = input, state = map)
410 def setpasswd(me, user, passwd):
411 """Service protocol: set the USER's password to PASSWD."""
412 me._dispatch(me._run_noout, 'set', [('u', user)], passwd + '\n')
414 def clearpasswd(me, user):
415 """Service protocol: clear the USER's password."""
416 me._dispatch(me._run_noout, 'clear', [('u', user)])
418 def mkpwent(me, user, passwd, fields):
419 """Service protocol: create a record for USER."""
420 me._dispatch(me._run_noout, 'mkpwent', [('u', user)])
422 def rmpwent(me, user):
423 """Service protocol: delete the record for USER."""
424 me._dispatch(me._run_noout, 'rmpwent', [('u', user)])
426 CONF.export('CommandRemoteService')
428 ###--------------------------------------------------------------------------
429 ### Services registry.
431 ## The registry of services.
433 CONF.export('SERVICES')
435 ## Set some default configuration.
436 CONF.DEFAULTS.update(
438 ## The master database, as a pair (MODNAME, MODARGS).
439 DB = ('sqlite3', [OS.path.join(HOME, 'chpwd.db')]),
441 ## The hash to use for our master password database.
442 HASH = H.CryptHash('md5'))
444 ## Post-configuration hook: add the master service.
446 def add_master_service():
447 dbmod, dbargs = CFG.DB
448 SERVICES['master'] = \
449 LocalService(B.DatabaseBackend(dbmod, dbargs,
450 'users', 'user', 'passwd'),
452 friendly = 'Password changing service')
453 for name, svc in SERVICES.iteritems():
454 if svc.name is None: svc.name = name
456 ###----- That's all, folks --------------------------------------------------