chiark / gitweb /
55172740d0f3ffd6bd89bf0e7ac26c46de598dba
[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 --------------------------------------------------