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