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 --------------------------------------------------