chiark / gitweb /
Found in crybaby's working tree.
[chopwood] / chpwd
CommitLineData
a2916c06
MW
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
26from __future__ import with_statement
27
28import contextlib as CTX
29import optparse as OP
30import os as OS; ENV = OS.environ
31import shlex as SL
32import sys as SYS
710c89c8 33import syslog as L
a2916c06
MW
34
35from auto import HOME, VERSION
36import cgi as CGI
37import cmdutil as CU
38import config as CONF; CFG = CONF.CFG
39import dbmaint as D
40import httpauth as HA
41import output as O; OUT = O.OUT
71d74dcf 42import service as S
a2916c06
MW
43import subcommand as SC
44import util as U
45
46for i in ['admin', 'cgi', 'remote', 'user']:
47 __import__('cmd-' + i)
48
49###--------------------------------------------------------------------------
50### Parsing command-line options.
51
52## Command-line options parser.
53OPTPARSE = 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 = """\
59Manage all of those annoying passwords.
60
61This is free software, and you can redistribute it and/or modify it
62under the terms of the GNU Affero General Public License
63<http://www.gnu.org/licenses/agpl-3.0.html>. For a `.tar.gz' file
64of the source code, use the `source' command.
65""")
66
67OPTS = None
68
69## Set up the global options.
70for 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',
2a875c57
MW
76 'default': ENV.get('CHPWD_CONFIG',
77 OS.path.join(HOME, 'chpwd.conf')),
a2916c06 78 'help': 'read configuration from FILE.' }),
46b48b43
MW
79 ('-i', '--ignore-policy', {
80 'dest': 'ignpol', 'default': False, 'action': 'store_true',
81 'help': 'ignore the operation policy (for administrators)' }),
bb623e8f
MW
82 ('-s', '--ssl', {
83 'dest': 'sslp', 'action': 'store_true',
84 'help': 'pretend CGI connection is carried over SSL/TLS' }),
a2916c06
MW
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
45a9a050
MW
90def parse_options():
91 """
92 Parse the main command-line options, returning the positional arguments.
93 """
94 global OPTS
95 OPTS, args = OPTPARSE.parse_args()
e3295bed 96 OPTPARSE.show_global_opts = False
c5412a5f 97 CFG.OPTS = OPTS
45a9a050
MW
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
a2916c06
MW
104###--------------------------------------------------------------------------
105### CGI dispatch.
106
107## The special variables, to be picked out by `cgiparse'.
108CGI.SPECIAL['%act'] = None
109CGI.SPECIAL['%nonce'] = None
ba8f1b92 110CGI.SPECIAL['%user'] = None
a2916c06
MW
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.
115def cgi_setup(ctx = 'cgi-noauth'):
a2916c06
MW
116 if OPTS: return
117 OPTPARSE.context = ctx
45a9a050 118 args = parse_options()
a2916c06
MW
119 if args: raise U.ExpectedError, (500, 'Unexpected arguments to CGI')
120 CONF.loadconfig(OPTS.config)
121 D.opendb()
122
123def 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
ba8f1b92
MW
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
a2916c06
MW
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
ba8f1b92
MW
197 if CU.USER != expuser: raise U.ExpectedError, (401, 'User mismatch')
198 CGI.STATE.kw['user'] = CU.USER
a2916c06
MW
199
200 ## Invoke the subcommand handler.
201 c.cgi(CGI.PARAM, CGI.PATH[i:])
202
203###--------------------------------------------------------------------------
204### Main dispatch.
205
206@CTX.contextmanager
207def 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
218if __name__ == '__main__':
219
710c89c8
MW
220 L.openlog(OS.path.basename(SYS.argv[0]), 0, L.LOG_AUTH)
221
a2916c06
MW
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()):
039df864
MW
227 with U.Escape() as CGI.HEADER_DONE:
228 with CGI.cgi_errors(cgi_setup):
229 dispatch_cgi()
a2916c06
MW
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():
a2916c06 236 with OUT.redirect_to(O.FileOutput()):
9fc9351d
MW
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')
fef23140 243 OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args[1:])
a2916c06
MW
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."""
45a9a050 252 args = parse_options()
a2916c06
MW
253 CONF.loadconfig(OPTS.config)
254 cmd = SL.split(ENV['SSH_ORIGINAL_COMMAND'])
71d74dcf 255 if args: raise U.ExpectedError, (500, 'Unexpected arguments via SSH')
a2916c06
MW
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():
a2916c06 263 with OUT.redirect_to(O.FileOutput()):
9fc9351d
MW
264 cmd = ssh_setup()
265 CU.set_user(ENV['CHPWD_SSH_USER'])
a2916c06
MW
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:
a2916c06 275 with OUT.redirect_to(O.RemoteOutput()):
9fc9351d 276 cmd = ssh_setup()
71d74dcf
MW
277 OPTPARSE.dispatch('remote', map(CGI.urldecode, cmd))
278 except U.ExpectedError, e:
a2916c06
MW
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():
71d74dcf 288 raise U.ExpectedError, (400, "Unabled to determine SSH context")
a2916c06
MW
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():
a2916c06 297 with OUT.redirect_to(O.FileOutput()):
9fc9351d
MW
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
a2916c06
MW
311 OPTPARSE.dispatch(ctx, args)
312
313###----- That's all, folks --------------------------------------------------