chiark / gitweb /
Found in crybaby's working tree.
[chopwood] / chpwd
1 #! /usr/bin/python
2 ###
3 ### Password management
4 ###
5 ### (c) 2012 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 contextlib as CTX
29 import optparse as OP
30 import os as OS; ENV = OS.environ
31 import shlex as SL
32 import sys as SYS
33 import syslog as L
34
35 from auto import HOME, VERSION
36 import cgi as CGI
37 import cmdutil as CU
38 import config as CONF; CFG = CONF.CFG
39 import dbmaint as D
40 import httpauth as HA
41 import output as O; OUT = O.OUT
42 import service as S
43 import subcommand as SC
44 import util as U
45
46 for i in ['admin', 'cgi', 'remote', 'user']:
47   __import__('cmd-' + i)
48
49 ###--------------------------------------------------------------------------
50 ### Parsing command-line options.
51
52 ## Command-line options parser.
53 OPTPARSE = SC.SubcommandOptionParser(
54   usage = '%prog SUBCOMMAND [ARGS ...]',
55   version = '%%prog, verion %s' % VERSION,
56   contexts = ['admin', 'userv', 'remote', 'cgi', 'cgi-query', 'cgi-noauth'],
57   commands = SC.COMMANDS,
58   description = """\
59 Manage all of those annoying passwords.
60
61 This is free software, and you can redistribute it and/or modify it
62 under the terms of the GNU Affero General Public License
63 <http://www.gnu.org/licenses/agpl-3.0.html>.  For a `.tar.gz' file
64 of the source code, use the `source' command.
65 """)
66
67 OPTS = None
68
69 ## Set up the global options.
70 for short, long, props in [
71   ('-c', '--context', {
72     'metavar': 'CONTEXT', 'dest': 'context', 'default': None,
73     'help': 'run commands with the given CONTEXT' }),
74   ('-f', '--config-file', {
75     'metavar': 'FILE', 'dest': 'config',
76     'default': ENV.get('CHPWD_CONFIG',
77                        OS.path.join(HOME, 'chpwd.conf')),
78     'help': 'read configuration from FILE.' }),
79   ('-i', '--ignore-policy', {
80     'dest': 'ignpol', 'default': False, 'action': 'store_true',
81     'help': 'ignore the operation policy (for administrators)' }),
82   ('-s', '--ssl', {
83     'dest': 'sslp', 'action': 'store_true',
84     'help': 'pretend CGI connection is carried over SSL/TLS' }),
85   ('-u', '--user', {
86     'metavar': 'USER', 'dest': 'user', 'default': None,
87     'help': "impersonate USER, and default context to `userv'." })]:
88   OPTPARSE.add_option(short, long, **props)
89
90 def parse_options():
91   """
92   Parse the main command-line options, returning the positional arguments.
93   """
94   global OPTS
95   OPTS, args = OPTPARSE.parse_args()
96   OPTPARSE.show_global_opts = False
97   CFG.OPTS = OPTS
98   ## It's tempting to load the configuration here.  Don't do that.  Some
99   ## contexts will want to check that the command line was handled properly
100   ## upstream before believing it for anything, such as executing arbitrary
101   ## Python code.
102   return args
103
104 ###--------------------------------------------------------------------------
105 ### CGI dispatch.
106
107 ## The special variables, to be picked out by `cgiparse'.
108 CGI.SPECIAL['%act'] = None
109 CGI.SPECIAL['%nonce'] = None
110 CGI.SPECIAL['%user'] = None
111
112 ## We don't want to parse arguments until we've settled on a context; but
113 ## issuing redirects in the early setup phase fails because we don't know
114 ## the script name.  So package the setup here.
115 def cgi_setup(ctx = 'cgi-noauth'):
116   if OPTS: return
117   OPTPARSE.context = ctx
118   args = parse_options()
119   if args: raise U.ExpectedError, (500, 'Unexpected arguments to CGI')
120   CONF.loadconfig(OPTS.config)
121   D.opendb()
122
123 def dispatch_cgi():
124   """Examine the CGI request and invoke the appropriate command."""
125
126   ## Start by picking apart the request.
127   CGI.cgiparse()
128
129   ## We'll be taking items off the trailing path.
130   i, np = 0, len(CGI.PATH)
131
132   ## Sometimes, we want to run several actions out of the same form, so the
133   ## subcommand name needs to be in the query string.  We use the special
134   ## variable `%act' for this.  If it's not set, then we use the first elment
135   ## of the path.
136   act = CGI.SPECIAL['%act']
137   if act is None:
138     if i >= np:
139       cgi_setup()
140       CGI.redirect(CGI.action('login'))
141       return
142     act = CGI.PATH[i]
143     i += 1
144
145   ## Figure out which context we're meant to be operating in, according to
146   ## the requested action.  Unknown actions result in an error here; known
147   ## actions where we don't have enough authorization send the user back to
148   ## the login page.
149   for ctx in ['cgi-noauth', 'cgi-query', 'cgi']:
150     try:
151       c = OPTPARSE.lookup_subcommand(act, exactp = True, context = ctx)
152     except U.ExpectedError, e:
153       if e.code != 404: raise
154     else:
155       break
156   else:
157     raise e
158
159   ## Parse the command line, and load configuration.
160   cgi_setup(ctx)
161
162   ## Check whether we have enough authorization.  There's always enough for
163   ## `cgi-noauth'.
164   if ctx != 'cgi-noauth':
165
166     ## The next part of the URL should be the user name, so that caches don't
167     ## cross things over.
168     expuser = CGI.SPECIAL['%user']
169     if expuser is None:
170       if i >= np: raise U.ExpectedError, (404, 'Missing user name')
171       expuser = CGI.PATH[i]
172       i += 1
173
174     ## If there's no token cookie, then we have to bail.
175     try: token = CGI.COOKIE['chpwd-token']
176     except KeyError:
177       CGI.redirect(CGI.action('login', why = 'NOAUTH'))
178       return
179
180     ## If we only want read-only access, then the cookie is good enough.
181     ## Otherwise we must check that a nonce was supplied, and that it is
182     ## correct.
183     if ctx == 'cgi-query':
184       nonce = None
185     else:
186       nonce = CGI.SPECIAL['%nonce']
187       if not nonce:
188         CGI.redirect(CGI.action('login', why = 'NONONCE'))
189         return
190
191     ## Verify the token and nonce.
192     try:
193       CU.USER = HA.check_auth(token, nonce)
194     except HA.AuthenticationFailed, e:
195       CGI.redirect(CGI.action('login', why = e.why))
196       return
197     if CU.USER != expuser: raise U.ExpectedError, (401, 'User mismatch')
198     CGI.STATE.kw['user'] = CU.USER
199
200   ## Invoke the subcommand handler.
201   c.cgi(CGI.PARAM, CGI.PATH[i:])
202
203 ###--------------------------------------------------------------------------
204 ### Main dispatch.
205
206 @CTX.contextmanager
207 def cli_errors():
208   """Catch expected errors and report them in the traditional Unix style."""
209   try:
210     yield None
211   except U.ExpectedError, e:
212     SYS.stderr.write('%s: %s\n' % (OS.path.basename(SYS.argv[0]), e.msg))
213     if 400 <= e.code < 500: SYS.exit(1)
214     else: SYS.exit(2)
215
216 ### Main dispatch.
217
218 if __name__ == '__main__':
219
220   L.openlog(OS.path.basename(SYS.argv[0]), 0, L.LOG_AUTH)
221
222   if 'REQUEST_METHOD' in ENV:
223     ## This looks like a CGI request.  The heavy lifting for authentication
224     ## over HTTP is done in `dispatch_cgi'.
225
226     with OUT.redirect_to(CGI.HTTPOutput()):
227       with U.Escape() as CGI.HEADER_DONE:
228         with CGI.cgi_errors(cgi_setup):
229           dispatch_cgi()
230
231   elif 'USERV_SERVICE' in ENV:
232     ## This is a Userv request.  The caller's user name is helpfully in the
233     ## `USERV_USER' environment variable.
234
235     with cli_errors():
236       with OUT.redirect_to(O.FileOutput()):
237         args = parse_options()
238         if not args or args[0] != 'userv':
239           raise U.ExpectedError, (500, 'missing userv token')
240         CONF.loadconfig(OPTS.config)
241         try: CU.set_user(ENV['USERV_USER'])
242         except KeyError: raise ExpectedError, (500, 'USERV_USER unset')
243         OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args[1:])
244
245   elif 'SSH_ORIGINAL_COMMAND' in ENV:
246     ## This looks like an SSH request; but we present two different
247     ## interfaces over SSH.  We must distinguish them -- carefully: they have
248     ## very different error-reporting conventions.
249
250     def ssh_setup():
251       """Extract and parse the client's request from where SSH left it."""
252       args = parse_options()
253       CONF.loadconfig(OPTS.config)
254       cmd = SL.split(ENV['SSH_ORIGINAL_COMMAND'])
255       if args: raise U.ExpectedError, (500, 'Unexpected arguments via SSH')
256       return cmd
257
258     if 'CHPWD_SSH_USER' in ENV:
259       ## Setting `CHPWD_SSH_USER' to a user name is the administrator's way
260       ## of telling us that this is a user request, so treat it like Userv.
261
262       with cli_errors():
263         with OUT.redirect_to(O.FileOutput()):
264           cmd = ssh_setup()
265           CU.set_user(ENV['CHPWD_SSH_USER'])
266           OPTPARSE.dispatch('userv', cmd)
267
268     elif 'CHPWD_SSH_MASTER' in ENV:
269       ## Setting `CHPWD_SSH_MASTER' to anything tells us that the client is
270       ## making a remote-service request.  We must turn on the protocol
271       ## decoration machinery, but don't need to -- mustn't, indeed -- set up
272       ## a user.
273
274       try:
275         with OUT.redirect_to(O.RemoteOutput()):
276           cmd = ssh_setup()
277           OPTPARSE.dispatch('remote', map(CGI.urldecode, cmd))
278       except U.ExpectedError, e:
279         print 'ERR', e.code, e.msg
280       else:
281         print 'OK'
282
283     else:
284       ## There's probably some strange botch in the `.ssh/authorized_keys'
285       ## file, but we can't do much about it from here.
286
287       with cli_errors():
288         raise U.ExpectedError, (400, "Unabled to determine SSH context")
289
290   else:
291     ## Plain old command line, apparently.  We default to administration
292     ## commands, but allow any kind, since this is useful for debugging, and
293     ## this isn't a security problem since our caller is just as privileged
294     ## as we are.
295
296     with cli_errors():
297       with OUT.redirect_to(O.FileOutput()):
298         args = parse_options()
299         CONF.loadconfig(OPTS.config)
300         CGI.SSLP = OPTS.sslp
301         ctx = OPTS.context
302         if OPTS.user:
303           CU.set_user(OPTS.user)
304           CGI.STATE.kw['user'] = OPTS.user
305           if ctx is None: ctx = 'userv'
306         else:
307           D.opendb()
308           if ctx is None:
309             ctx = 'admin'
310             OPTPARSE.show_global_opts = True
311         OPTPARSE.dispatch(ctx, args)
312
313 ###----- That's all, folks --------------------------------------------------