Put the user's name in post-authentication requests.
[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 CGI.SPECIAL['%user'] = None
91
92 ## We don't want to parse arguments until we've settled on a context; but
93 ## issuing redirects in the early setup phase fails because we don't know
94 ## the script name. So package the setup here.
95 def cgi_setup(ctx = 'cgi-noauth'):
96 global OPTS
97 if OPTS: return
98 OPTPARSE.context = ctx
99 OPTS, args = OPTPARSE.parse_args()
100 if args: raise U.ExpectedError, (500, 'Unexpected arguments to CGI')
101 CONF.loadconfig(OPTS.config)
102 D.opendb()
103
104 def dispatch_cgi():
105 """Examine the CGI request and invoke the appropriate command."""
106
107 ## Start by picking apart the request.
108 CGI.cgiparse()
109
110 ## We'll be taking items off the trailing path.
111 i, np = 0, len(CGI.PATH)
112
113 ## Sometimes, we want to run several actions out of the same form, so the
114 ## subcommand name needs to be in the query string. We use the special
115 ## variable `%act' for this. If it's not set, then we use the first elment
116 ## of the path.
117 act = CGI.SPECIAL['%act']
118 if act is None:
119 if i >= np:
120 cgi_setup()
121 CGI.redirect(CGI.action('login'))
122 return
123 act = CGI.PATH[i]
124 i += 1
125
126 ## Figure out which context we're meant to be operating in, according to
127 ## the requested action. Unknown actions result in an error here; known
128 ## actions where we don't have enough authorization send the user back to
129 ## the login page.
130 for ctx in ['cgi-noauth', 'cgi-query', 'cgi']:
131 try:
132 c = OPTPARSE.lookup_subcommand(act, exactp = True, context = ctx)
133 except U.ExpectedError, e:
134 if e.code != 404: raise
135 else:
136 break
137 else:
138 raise e
139
140 ## Parse the command line, and load configuration.
141 cgi_setup(ctx)
142
143 ## Check whether we have enough authorization. There's always enough for
144 ## `cgi-noauth'.
145 if ctx != 'cgi-noauth':
146
147 ## The next part of the URL should be the user name, so that caches don't
148 ## cross things over.
149 expuser = CGI.SPECIAL['%user']
150 if expuser is None:
151 if i >= np: raise U.ExpectedError, (404, 'Missing user name')
152 expuser = CGI.PATH[i]
153 i += 1
154
155 ## If there's no token cookie, then we have to bail.
156 try: token = CGI.COOKIE['chpwd-token']
157 except KeyError:
158 CGI.redirect(CGI.action('login', why = 'NOAUTH'))
159 return
160
161 ## If we only want read-only access, then the cookie is good enough.
162 ## Otherwise we must check that a nonce was supplied, and that it is
163 ## correct.
164 if ctx == 'cgi-query':
165 nonce = None
166 else:
167 nonce = CGI.SPECIAL['%nonce']
168 if not nonce:
169 CGI.redirect(CGI.action('login', why = 'NONONCE'))
170 return
171
172 ## Verify the token and nonce.
173 try:
174 CU.USER = HA.check_auth(token, nonce)
175 except HA.AuthenticationFailed, e:
176 CGI.redirect(CGI.action('login', why = e.why))
177 return
178 if CU.USER != expuser: raise U.ExpectedError, (401, 'User mismatch')
179 CGI.STATE.kw['user'] = CU.USER
180
181 ## Invoke the subcommand handler.
182 c.cgi(CGI.PARAM, CGI.PATH[i:])
183
184 ###--------------------------------------------------------------------------
185 ### Main dispatch.
186
187 @CTX.contextmanager
188 def cli_errors():
189 """Catch expected errors and report them in the traditional Unix style."""
190 try:
191 yield None
192 except U.ExpectedError, e:
193 SYS.stderr.write('%s: %s\n' % (OS.path.basename(SYS.argv[0]), e.msg))
194 if 400 <= e.code < 500: SYS.exit(1)
195 else: SYS.exit(2)
196
197 ### Main dispatch.
198
199 if __name__ == '__main__':
200
201 if 'REQUEST_METHOD' in ENV:
202 ## This looks like a CGI request. The heavy lifting for authentication
203 ## over HTTP is done in `dispatch_cgi'.
204
205 with OUT.redirect_to(CGI.HTTPOutput()):
206 with CGI.cgi_errors(cgi_setup): dispatch_cgi()
207
208 elif 'USERV_SERVICE' in ENV:
209 ## This is a Userv request. The caller's user name is helpfully in the
210 ## `USERV_USER' environment variable.
211
212 with cli_errors():
213 OPTS, args = OPTPARSE.parse_args()
214 CONF.loadconfig(OPTS.config)
215 try: CU.set_user(ENV['USERV_USER'])
216 except KeyError: raise ExpectedError, (500, 'USERV_USER unset')
217 with OUT.redirect_to(O.FileOutput()):
218 OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args)
219
220 elif 'SSH_ORIGINAL_COMMAND' in ENV:
221 ## This looks like an SSH request; but we present two different
222 ## interfaces over SSH. We must distinguish them -- carefully: they have
223 ## very different error-reporting conventions.
224
225 def ssh_setup():
226 """Extract and parse the client's request from where SSH left it."""
227 global OPTS
228 OPTS, args = OPTPARSE.parse_args()
229 CONF.loadconfig(OPTS.config)
230 cmd = SL.split(ENV['SSH_ORIGINAL_COMMAND'])
231 if args: raise ExpectedError, (500, 'Unexpected arguments via SSH')
232 return cmd
233
234 if 'CHPWD_SSH_USER' in ENV:
235 ## Setting `CHPWD_SSH_USER' to a user name is the administrator's way
236 ## of telling us that this is a user request, so treat it like Userv.
237
238 with cli_errors():
239 cmd = ssh_setup()
240 CU.set_user(ENV['CHPWD_SSH_USER'])
241 SERVICES['master'].find(USER)
242 with OUT.redirect_to(O.FileOutput()):
243 OPTPARSE.dispatch('userv', cmd)
244
245 elif 'CHPWD_SSH_MASTER' in ENV:
246 ## Setting `CHPWD_SSH_MASTER' to anything tells us that the client is
247 ## making a remote-service request. We must turn on the protocol
248 ## decoration machinery, but don't need to -- mustn't, indeed -- set up
249 ## a user.
250
251 try:
252 cmd = ssh_setup()
253 with OUT.redirect_to(O.RemoteOutput()):
254 OPTPARSE.dispatch('remote', map(urldecode, cmd))
255 except ExpectedError, e:
256 print 'ERR', e.code, e.msg
257 else:
258 print 'OK'
259
260 else:
261 ## There's probably some strange botch in the `.ssh/authorized_keys'
262 ## file, but we can't do much about it from here.
263
264 with cli_errors():
265 raise ExpectedError, (400, "Unabled to determine SSH context")
266
267 else:
268 ## Plain old command line, apparently. We default to administration
269 ## commands, but allow any kind, since this is useful for debugging, and
270 ## this isn't a security problem since our caller is just as privileged
271 ## as we are.
272
273 with cli_errors():
274 OPTS, args = OPTPARSE.parse_args()
275 CONF.loadconfig(OPTS.config)
276 CGI.SSLP = OPTS.sslp
277 ctx = OPTS.context
278 if OPTS.user:
279 CU.set_user(OPTS.user)
280 if ctx is None: ctx = 'userv'
281 else:
282 D.opendb()
283 if ctx is None: ctx = 'admin'
284 with OUT.redirect_to(O.FileOutput()):
285 OPTPARSE.dispatch(ctx, args)
286
287 ###----- That's all, folks --------------------------------------------------