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