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
35 from auto
import HOME
, VERSION
38 import config
as CONF
; CFG
= CONF
.CFG
41 import output
as O
; OUT
= O
.OUT
43 import subcommand
as SC
46 for i
in ['admin', 'cgi', 'remote', 'user']:
47 __import__('cmd-' + i
)
49 ###--------------------------------------------------------------------------
50 ### Parsing command-line options.
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
,
59 Manage all of those annoying passwords.
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.
69 ## Set up the global options.
70 for short
, long, props
in [
72 'metavar': 'CONTEXT', 'dest': 'context', 'default': None,
73 'help': 'run commands with the given CONTEXT' }),
74 ('-f', '--config-file', {
75 'metavar': 'FILE', 'dest': 'config',
76 'default': ENV
.get('CHPWD_CONFIG',
77 OS
.path
.join(HOME
, 'chpwd.conf')),
78 'help': 'read configuration from FILE.' }),
80 'dest': 'sslp', 'action': 'store_true',
81 'help': 'pretend CGI connection is carried over SSL/TLS' }),
83 'metavar': 'USER', 'dest': 'user', 'default': None,
84 'help': "impersonate USER, and default context to `userv'." })]:
85 OPTPARSE
.add_option(short
, long, **props
)
89 Parse the main command-line options, returning the positional arguments.
92 OPTS
, args
= OPTPARSE
.parse_args()
93 OPTPARSE
.show_global_opts
= False
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
101 ###--------------------------------------------------------------------------
104 ## The special variables, to be picked out by `cgiparse'.
105 CGI
.SPECIAL
['%act'] = None
106 CGI
.SPECIAL
['%nonce'] = None
107 CGI
.SPECIAL
['%user'] = None
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'):
114 OPTPARSE
.context
= ctx
115 args
= parse_options()
116 if args
: raise U
.ExpectedError
, (500, 'Unexpected arguments to CGI')
117 CONF
.loadconfig(OPTS
.config
)
121 """Examine the CGI request and invoke the appropriate command."""
123 ## Start by picking apart the request.
126 ## We'll be taking items off the trailing path.
127 i
, np
= 0, len(CGI
.PATH
)
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
133 act
= CGI
.SPECIAL
['%act']
137 CGI
.redirect(CGI
.action('login'))
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
146 for ctx
in ['cgi-noauth', 'cgi-query', 'cgi']:
148 c
= OPTPARSE
.lookup_subcommand(act
, exactp
= True, context
= ctx
)
149 except U
.ExpectedError
, e
:
150 if e
.code
!= 404: raise
156 ## Parse the command line, and load configuration.
159 ## Check whether we have enough authorization. There's always enough for
161 if ctx
!= 'cgi-noauth':
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']
167 if i
>= np
: raise U
.ExpectedError
, (404, 'Missing user name')
168 expuser
= CGI
.PATH
[i
]
171 ## If there's no token cookie, then we have to bail.
172 try: token
= CGI
.COOKIE
['chpwd-token']
174 CGI
.redirect(CGI
.action('login', why
= 'NOAUTH'))
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
180 if ctx
== 'cgi-query':
183 nonce
= CGI
.SPECIAL
['%nonce']
185 CGI
.redirect(CGI
.action('login', why
= 'NONONCE'))
188 ## Verify the token and nonce.
190 CU
.USER
= HA
.check_auth(token
, nonce
)
191 except HA
.AuthenticationFailed
, e
:
192 CGI
.redirect(CGI
.action('login', why
= e
.why
))
194 if CU
.USER
!= expuser
: raise U
.ExpectedError
, (401, 'User mismatch')
195 CGI
.STATE
.kw
['user'] = CU
.USER
197 ## Invoke the subcommand handler.
198 c
.cgi(CGI
.PARAM
, CGI
.PATH
[i
:])
200 ###--------------------------------------------------------------------------
205 """Catch expected errors and report them in the traditional Unix style."""
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)
215 if __name__
== '__main__':
217 L
.openlog(OS
.path
.basename(SYS
.argv
[0]), 0, L
.LOG_AUTH
)
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'.
223 with OUT
.redirect_to(CGI
.HTTPOutput()):
224 with U
.Escape() as CGI
.HEADER_DONE
:
225 with CGI
.cgi_errors(cgi_setup
):
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.
233 with OUT
.redirect_to(O
.FileOutput()):
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')
240 OPTPARSE
.dispatch('userv', [ENV
['USERV_SERVICE']] + args
[1:])
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.
248 """Extract and parse the client's request from where SSH left it."""
249 args
= parse_options()
250 CONF
.loadconfig(OPTS
.config
)
251 cmd
= SL
.split(ENV
['SSH_ORIGINAL_COMMAND'])
252 if args
: raise U
.ExpectedError
, (500, 'Unexpected arguments via SSH')
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.
260 with OUT
.redirect_to(O
.FileOutput()):
262 CU
.set_user(ENV
['CHPWD_SSH_USER'])
263 OPTPARSE
.dispatch('userv', cmd
)
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
272 with OUT
.redirect_to(O
.RemoteOutput()):
274 OPTPARSE
.dispatch('remote', map(CGI
.urldecode
, cmd
))
275 except U
.ExpectedError
, e
:
276 print 'ERR', e
.code
, e
.msg
281 ## There's probably some strange botch in the `.ssh/authorized_keys'
282 ## file, but we can't do much about it from here.
285 raise U
.ExpectedError
, (400, "Unabled to determine SSH context")
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
294 with OUT
.redirect_to(O
.FileOutput()):
295 args
= parse_options()
296 CONF
.loadconfig(OPTS
.config
)
300 CU
.set_user(OPTS
.user
)
301 CGI
.STATE
.kw
['user'] = OPTS
.user
302 if ctx
is None: ctx
= 'userv'
307 OPTPARSE
.show_global_opts
= True
308 OPTPARSE
.dispatch(ctx
, args
)
310 ###----- That's all, folks --------------------------------------------------