#! /usr/bin/python ### ### Password management ### ### (c) 2012 Mark Wooding ### ###----- Licensing notice --------------------------------------------------- ### ### This file is part of Chopwood: a password-changing service. ### ### Chopwood is free software; you can redistribute it and/or modify ### it under the terms of the GNU Affero General Public License as ### published by the Free Software Foundation; either version 3 of the ### License, or (at your option) any later version. ### ### Chopwood is distributed in the hope that it will be useful, ### but WITHOUT ANY WARRANTY; without even the implied warranty of ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ### GNU Affero General Public License for more details. ### ### You should have received a copy of the GNU Affero General Public ### License along with Chopwood; if not, see ### . from __future__ import with_statement import contextlib as CTX import optparse as OP import os as OS; ENV = OS.environ import shlex as SL import sys as SYS import syslog as L from auto import HOME, VERSION import cgi as CGI import cmdutil as CU import config as CONF; CFG = CONF.CFG import dbmaint as D import httpauth as HA import output as O; OUT = O.OUT import service as S import subcommand as SC import util as U for i in ['admin', 'cgi', 'remote', 'user']: __import__('cmd-' + i) ###-------------------------------------------------------------------------- ### Parsing command-line options. ## Command-line options parser. OPTPARSE = SC.SubcommandOptionParser( usage = '%prog SUBCOMMAND [ARGS ...]', version = '%%prog, verion %s' % VERSION, contexts = ['admin', 'userv', 'remote', 'cgi', 'cgi-query', 'cgi-noauth'], commands = SC.COMMANDS, description = """\ Manage all of those annoying passwords. This is free software, and you can redistribute it and/or modify it under the terms of the GNU Affero General Public License . For a `.tar.gz' file of the source code, use the `source' command. """) OPTS = None ## Set up the global options. for short, long, props in [ ('-c', '--context', { 'metavar': 'CONTEXT', 'dest': 'context', 'default': None, 'help': 'run commands with the given CONTEXT' }), ('-f', '--config-file', { 'metavar': 'FILE', 'dest': 'config', 'default': ENV.get('CHPWD_CONFIG', OS.path.join(HOME, 'chpwd.conf')), 'help': 'read configuration from FILE.' }), ('-s', '--ssl', { 'dest': 'sslp', 'action': 'store_true', 'help': 'pretend CGI connection is carried over SSL/TLS' }), ('-u', '--user', { 'metavar': 'USER', 'dest': 'user', 'default': None, 'help': "impersonate USER, and default context to `userv'." })]: OPTPARSE.add_option(short, long, **props) ###-------------------------------------------------------------------------- ### CGI dispatch. ## The special variables, to be picked out by `cgiparse'. CGI.SPECIAL['%act'] = None CGI.SPECIAL['%nonce'] = None CGI.SPECIAL['%user'] = None ## We don't want to parse arguments until we've settled on a context; but ## issuing redirects in the early setup phase fails because we don't know ## the script name. So package the setup here. def cgi_setup(ctx = 'cgi-noauth'): global OPTS if OPTS: return OPTPARSE.context = ctx OPTS, args = OPTPARSE.parse_args() if args: raise U.ExpectedError, (500, 'Unexpected arguments to CGI') CONF.loadconfig(OPTS.config) D.opendb() def dispatch_cgi(): """Examine the CGI request and invoke the appropriate command.""" ## Start by picking apart the request. CGI.cgiparse() ## We'll be taking items off the trailing path. i, np = 0, len(CGI.PATH) ## Sometimes, we want to run several actions out of the same form, so the ## subcommand name needs to be in the query string. We use the special ## variable `%act' for this. If it's not set, then we use the first elment ## of the path. act = CGI.SPECIAL['%act'] if act is None: if i >= np: cgi_setup() CGI.redirect(CGI.action('login')) return act = CGI.PATH[i] i += 1 ## Figure out which context we're meant to be operating in, according to ## the requested action. Unknown actions result in an error here; known ## actions where we don't have enough authorization send the user back to ## the login page. for ctx in ['cgi-noauth', 'cgi-query', 'cgi']: try: c = OPTPARSE.lookup_subcommand(act, exactp = True, context = ctx) except U.ExpectedError, e: if e.code != 404: raise else: break else: raise e ## Parse the command line, and load configuration. cgi_setup(ctx) ## Check whether we have enough authorization. There's always enough for ## `cgi-noauth'. if ctx != 'cgi-noauth': ## The next part of the URL should be the user name, so that caches don't ## cross things over. expuser = CGI.SPECIAL['%user'] if expuser is None: if i >= np: raise U.ExpectedError, (404, 'Missing user name') expuser = CGI.PATH[i] i += 1 ## If there's no token cookie, then we have to bail. try: token = CGI.COOKIE['chpwd-token'] except KeyError: CGI.redirect(CGI.action('login', why = 'NOAUTH')) return ## If we only want read-only access, then the cookie is good enough. ## Otherwise we must check that a nonce was supplied, and that it is ## correct. if ctx == 'cgi-query': nonce = None else: nonce = CGI.SPECIAL['%nonce'] if not nonce: CGI.redirect(CGI.action('login', why = 'NONONCE')) return ## Verify the token and nonce. try: CU.USER = HA.check_auth(token, nonce) except HA.AuthenticationFailed, e: CGI.redirect(CGI.action('login', why = e.why)) return if CU.USER != expuser: raise U.ExpectedError, (401, 'User mismatch') CGI.STATE.kw['user'] = CU.USER ## Invoke the subcommand handler. c.cgi(CGI.PARAM, CGI.PATH[i:]) ###-------------------------------------------------------------------------- ### Main dispatch. @CTX.contextmanager def cli_errors(): """Catch expected errors and report them in the traditional Unix style.""" try: yield None except U.ExpectedError, e: SYS.stderr.write('%s: %s\n' % (OS.path.basename(SYS.argv[0]), e.msg)) if 400 <= e.code < 500: SYS.exit(1) else: SYS.exit(2) ### Main dispatch. if __name__ == '__main__': L.openlog(OS.path.basename(SYS.argv[0]), 0, L.LOG_AUTH) if 'REQUEST_METHOD' in ENV: ## This looks like a CGI request. The heavy lifting for authentication ## over HTTP is done in `dispatch_cgi'. with OUT.redirect_to(CGI.HTTPOutput()): with U.Escape() as CGI.HEADER_DONE: with CGI.cgi_errors(cgi_setup): dispatch_cgi() elif 'USERV_SERVICE' in ENV: ## This is a Userv request. The caller's user name is helpfully in the ## `USERV_USER' environment variable. with cli_errors(): OPTS, args = OPTPARSE.parse_args() CONF.loadconfig(OPTS.config) try: CU.set_user(ENV['USERV_USER']) except KeyError: raise ExpectedError, (500, 'USERV_USER unset') with OUT.redirect_to(O.FileOutput()): OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args) elif 'SSH_ORIGINAL_COMMAND' in ENV: ## This looks like an SSH request; but we present two different ## interfaces over SSH. We must distinguish them -- carefully: they have ## very different error-reporting conventions. def ssh_setup(): """Extract and parse the client's request from where SSH left it.""" global OPTS OPTS, args = OPTPARSE.parse_args() CONF.loadconfig(OPTS.config) cmd = SL.split(ENV['SSH_ORIGINAL_COMMAND']) if args: raise U.ExpectedError, (500, 'Unexpected arguments via SSH') return cmd if 'CHPWD_SSH_USER' in ENV: ## Setting `CHPWD_SSH_USER' to a user name is the administrator's way ## of telling us that this is a user request, so treat it like Userv. with cli_errors(): cmd = ssh_setup() CU.set_user(ENV['CHPWD_SSH_USER']) with OUT.redirect_to(O.FileOutput()): OPTPARSE.dispatch('userv', cmd) elif 'CHPWD_SSH_MASTER' in ENV: ## Setting `CHPWD_SSH_MASTER' to anything tells us that the client is ## making a remote-service request. We must turn on the protocol ## decoration machinery, but don't need to -- mustn't, indeed -- set up ## a user. try: cmd = ssh_setup() with OUT.redirect_to(O.RemoteOutput()): OPTPARSE.dispatch('remote', map(CGI.urldecode, cmd)) except U.ExpectedError, e: print 'ERR', e.code, e.msg else: print 'OK' else: ## There's probably some strange botch in the `.ssh/authorized_keys' ## file, but we can't do much about it from here. with cli_errors(): raise U.ExpectedError, (400, "Unabled to determine SSH context") else: ## Plain old command line, apparently. We default to administration ## commands, but allow any kind, since this is useful for debugging, and ## this isn't a security problem since our caller is just as privileged ## as we are. with cli_errors(): OPTS, args = OPTPARSE.parse_args() CONF.loadconfig(OPTS.config) CGI.SSLP = OPTS.sslp ctx = OPTS.context if OPTS.user: CU.set_user(OPTS.user) CGI.STATE.kw['user'] = OPTS.user if ctx is None: ctx = 'userv' else: D.opendb() if ctx is None: ctx = 'admin' with OUT.redirect_to(O.FileOutput()): OPTPARSE.dispatch(ctx, args) ###----- That's all, folks --------------------------------------------------