3 ### Password management
5 ### (c) 2012 Mark Wooding
8 ###----- Licensing notice ---------------------------------------------------
10 ### This file is part of Chopwood: a password-changing service.
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.
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.
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/>.
26 from __future__ import with_statement
28 import contextlib as CTX
30 import os as OS; ENV = OS.environ
34 from auto import HOME, VERSION
37 import config as CONF; CFG = CONF.CFG
40 import output as O; OUT = O.OUT
41 import subcommand as SC
44 for i in ['admin', 'cgi', 'remote', 'user']:
45 __import__('cmd-' + i)
47 ###--------------------------------------------------------------------------
48 ### Parsing command-line options.
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,
57 Manage all of those annoying passwords.
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.
67 ## Set up the global options.
68 for short, long, props in [
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': ENV.get('CHPWD_CONFIG',
75 OS.path.join(HOME, 'chpwd.conf')),
76 'help': 'read configuration from FILE.' }),
78 'dest': 'sslp', 'action': 'store_true',
79 'help': 'pretend CGI connection is carried over SSL/TLS' }),
81 'metavar': 'USER', 'dest': 'user', 'default': None,
82 'help': "impersonate USER, and default context to `userv'." })]:
83 OPTPARSE.add_option(short, long, **props)
85 ###--------------------------------------------------------------------------
88 ## The special variables, to be picked out by `cgiparse'.
89 CGI.SPECIAL['%act'] = None
90 CGI.SPECIAL['%nonce'] = None
91 CGI.SPECIAL['%user'] = None
93 ## We don't want to parse arguments until we've settled on a context; but
94 ## issuing redirects in the early setup phase fails because we don't know
95 ## the script name. So package the setup here.
96 def cgi_setup(ctx = 'cgi-noauth'):
99 OPTPARSE.context = ctx
100 OPTS, args = OPTPARSE.parse_args()
101 if args: raise U.ExpectedError, (500, 'Unexpected arguments to CGI')
102 CONF.loadconfig(OPTS.config)
106 """Examine the CGI request and invoke the appropriate command."""
108 ## Start by picking apart the request.
111 ## We'll be taking items off the trailing path.
112 i, np = 0, len(CGI.PATH)
114 ## Sometimes, we want to run several actions out of the same form, so the
115 ## subcommand name needs to be in the query string. We use the special
116 ## variable `%act' for this. If it's not set, then we use the first elment
118 act = CGI.SPECIAL['%act']
122 CGI.redirect(CGI.action('login'))
127 ## Figure out which context we're meant to be operating in, according to
128 ## the requested action. Unknown actions result in an error here; known
129 ## actions where we don't have enough authorization send the user back to
131 for ctx in ['cgi-noauth', 'cgi-query', 'cgi']:
133 c = OPTPARSE.lookup_subcommand(act, exactp = True, context = ctx)
134 except U.ExpectedError, e:
135 if e.code != 404: raise
141 ## Parse the command line, and load configuration.
144 ## Check whether we have enough authorization. There's always enough for
146 if ctx != 'cgi-noauth':
148 ## The next part of the URL should be the user name, so that caches don't
149 ## cross things over.
150 expuser = CGI.SPECIAL['%user']
152 if i >= np: raise U.ExpectedError, (404, 'Missing user name')
153 expuser = CGI.PATH[i]
156 ## If there's no token cookie, then we have to bail.
157 try: token = CGI.COOKIE['chpwd-token']
159 CGI.redirect(CGI.action('login', why = 'NOAUTH'))
162 ## If we only want read-only access, then the cookie is good enough.
163 ## Otherwise we must check that a nonce was supplied, and that it is
165 if ctx == 'cgi-query':
168 nonce = CGI.SPECIAL['%nonce']
170 CGI.redirect(CGI.action('login', why = 'NONONCE'))
173 ## Verify the token and nonce.
175 CU.USER = HA.check_auth(token, nonce)
176 except HA.AuthenticationFailed, e:
177 CGI.redirect(CGI.action('login', why = e.why))
179 if CU.USER != expuser: raise U.ExpectedError, (401, 'User mismatch')
180 CGI.STATE.kw['user'] = CU.USER
182 ## Invoke the subcommand handler.
183 c.cgi(CGI.PARAM, CGI.PATH[i:])
185 ###--------------------------------------------------------------------------
190 """Catch expected errors and report them in the traditional Unix style."""
193 except U.ExpectedError, e:
194 SYS.stderr.write('%s: %s\n' % (OS.path.basename(SYS.argv[0]), e.msg))
195 if 400 <= e.code < 500: SYS.exit(1)
200 if __name__ == '__main__':
202 if 'REQUEST_METHOD' in ENV:
203 ## This looks like a CGI request. The heavy lifting for authentication
204 ## over HTTP is done in `dispatch_cgi'.
206 with OUT.redirect_to(CGI.HTTPOutput()):
207 with CGI.cgi_errors(cgi_setup): dispatch_cgi()
209 elif 'USERV_SERVICE' in ENV:
210 ## This is a Userv request. The caller's user name is helpfully in the
211 ## `USERV_USER' environment variable.
214 OPTS, args = OPTPARSE.parse_args()
215 CONF.loadconfig(OPTS.config)
216 try: CU.set_user(ENV['USERV_USER'])
217 except KeyError: raise ExpectedError, (500, 'USERV_USER unset')
218 with OUT.redirect_to(O.FileOutput()):
219 OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args)
221 elif 'SSH_ORIGINAL_COMMAND' in ENV:
222 ## This looks like an SSH request; but we present two different
223 ## interfaces over SSH. We must distinguish them -- carefully: they have
224 ## very different error-reporting conventions.
227 """Extract and parse the client's request from where SSH left it."""
229 OPTS, args = OPTPARSE.parse_args()
230 CONF.loadconfig(OPTS.config)
231 cmd = SL.split(ENV['SSH_ORIGINAL_COMMAND'])
232 if args: raise ExpectedError, (500, 'Unexpected arguments via SSH')
235 if 'CHPWD_SSH_USER' in ENV:
236 ## Setting `CHPWD_SSH_USER' to a user name is the administrator's way
237 ## of telling us that this is a user request, so treat it like Userv.
241 CU.set_user(ENV['CHPWD_SSH_USER'])
242 SERVICES['master'].find(USER)
243 with OUT.redirect_to(O.FileOutput()):
244 OPTPARSE.dispatch('userv', cmd)
246 elif 'CHPWD_SSH_MASTER' in ENV:
247 ## Setting `CHPWD_SSH_MASTER' to anything tells us that the client is
248 ## making a remote-service request. We must turn on the protocol
249 ## decoration machinery, but don't need to -- mustn't, indeed -- set up
254 with OUT.redirect_to(O.RemoteOutput()):
255 OPTPARSE.dispatch('remote', map(urldecode, cmd))
256 except ExpectedError, e:
257 print 'ERR', e.code, e.msg
262 ## There's probably some strange botch in the `.ssh/authorized_keys'
263 ## file, but we can't do much about it from here.
266 raise ExpectedError, (400, "Unabled to determine SSH context")
269 ## Plain old command line, apparently. We default to administration
270 ## commands, but allow any kind, since this is useful for debugging, and
271 ## this isn't a security problem since our caller is just as privileged
275 OPTS, args = OPTPARSE.parse_args()
276 CONF.loadconfig(OPTS.config)
280 CU.set_user(OPTS.user)
281 if ctx is None: ctx = 'userv'
284 if ctx is None: ctx = 'admin'
285 with OUT.redirect_to(O.FileOutput()):
286 OPTPARSE.dispatch(ctx, args)
288 ###----- That's all, folks --------------------------------------------------