Initial commit.
[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 ('-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'.
85 CGI.SPECIAL['%act'] = None
86 CGI.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.
91 def 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
100 def 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
174 def 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
185 if __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 --------------------------------------------------