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