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: 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.
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
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.
64 UnknownUser = B.UnknownUser
66 class IncorrectPassword (Exception):
68 A failed password check is reported via an exception.
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
76 class BasicService (object):
78 A simple base class for services.
81 def __init__(me, friendly, *args, **kw):
82 super(BasicService, me).__init__(*args)
83 me.friendly = friendly
86 ###--------------------------------------------------------------------------
89 class Account (object):
91 An account represents information about a user of a particular service.
93 From here, we can implement the service protocol operations, and also check
96 Users are expected to acquire account objects via the `lookup' method of a
97 `LocalService' or similar.
100 def __init__(me, svc, rec):
102 Create a new account, for the service SVC, holding the user record REC.
108 def check(me, passwd):
110 Check the password PASSWD against the information we have. If the
111 password is correct, return normally; otherwise, raise
114 if not me._hash.check(me._rec, me._rec.passwd, passwd):
115 raise IncorrectPassword
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
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
130 class LocalService (BasicService):
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.)
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.
142 def __init__(me, backend, hash, *args, **kw):
144 Create a new local service with a FRIENDLY name, using the given BACKEND
147 super(LocalService, me).__init__(*args, **kw)
152 """Find the named USER, returning an `Account' object."""
153 rec = me._be.lookup(user)
154 return Account(me, rec)
156 def setpasswd(me, user, passwd):
157 """Service protcol: set USER's password to PASSWD."""
158 me.find(user).setpasswd(passwd)
160 def clearpasswd(me, user):
161 """Service protocol: clear USER's password, preventing logins."""
162 me.find(user).clearpasswd()
164 CONF.export('LocalService')
166 ###--------------------------------------------------------------------------
169 class BasicRemoteService (BasicService):
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.
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
185 def _run(me, cmd, input = None):
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
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.
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.
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)
215 ## If the program failed then report this: it obviously didn't work
218 raise U.ExpectedError, (
219 500, 'Remote service error: %r (rc = %d)' % (err, st))
221 ## Split a word off the front of a string; return the word and the
224 ww = line.split(None, 1)
226 if not n: return None
227 elif n == 1: return ww[0], ''
230 ## Work through the lines, parsing them.
232 for line in out.splitlines():
233 type, rest = nextword(line)
235 code, msg = nextword(rest)
236 raise U.ExpectedError, (int(code), msg)
242 raise U.ExpectedError, \
243 (500, 'Incomprehensible reply from remote service: %r' % line)
245 ## If we didn't get any kind of verdict then something weird has
248 raise U.ExpectedError, (500, 'No reply from remote service')
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')
255 class SSHRemoteService (BasicRemoteService):
257 A remote service transported over SSH.
259 The remote service is given commands of the form
262 Set USER's password for SERVICE to the password provided on the next
263 line of standard input.
266 Clear the USER's password for SERVICE.
268 Arguments are form-url-encoded, since SSH doesn't preserve token boundaries
269 in its argument list.
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'.
277 def __init__(me, remote, name, *args, **kw):
279 Initialize an SSH remote service, contacting the SSH user REMOTE
280 (probably of the form `LOGIN@HOSTNAME') and referring to the service
283 super(SSHRemoteService, me).__init__(*args, **kw)
288 """Description of the remote service."""
289 return "`%s' via SSH to `%s'" % (me._name, me._remote),
292 """Format a command for SSH. Mainly escaping arguments."""
293 return ['ssh', me._remote, ' '.join(map(CGI.urlencode, cmd))]
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')
299 def clearpasswd(me, user):
300 """Service protocol: clear the USER's password."""
301 me._run_noout(['clear', me._name, user])
303 CONF.export('SSHRemoteService')
305 class CommandRemoteService (BasicRemoteService):
307 A remote service transported over a standard Unix command.
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.
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.
322 The placeholders are as follows.
325 `%%' a single `%' character
328 R_PAT = RX.compile('%(.)')
330 def __init__(me, set, clear, *args, **kw):
332 Initialize the command remote service.
334 super(CommandRemoteService, me).__init__(*args, **kw)
337 me._map = dict(u = user)
340 """Return the substitution for the placeholder `%C'."""
341 return me._map.get(c, c)
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]
347 def setpasswd(me, user, passwd):
348 """Service protocol: set the USER's password to PASSWD."""
349 me._run_noout(me._set, passwd + '\n')
351 def clearpasswd(me, user):
352 """Service protocol: clear the USER's password."""
353 me._run_noout(me._clear)
355 CONF.export('CommandRemoteService')
357 ###--------------------------------------------------------------------------
358 ### Services registry.
360 ## The registry of services.
362 CONF.export('SERVICES')
364 ## Set some default configuration.
365 CONF.DEFAULTS.update(
367 ## The master database, as a pair (MODNAME, MODARGS).
368 DB = ('sqlite3', [OS.path.join(HOME, 'chpwd.db')]),
370 ## The hash to use for our master password database.
371 HASH = H.CryptHash('md5'))
373 ## Post-configuration hook: add the master service.
375 def add_master_service():
376 dbmod, dbargs = CFG.DB
377 SERVICES['master'] = \
378 LocalService(B.DatabaseBackend(dbmod, dbargs,
379 'users', 'user', 'passwd'),
381 friendly = 'Password changing service')
383 ###----- That's all, folks --------------------------------------------------