#! /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)
def parse_options():
"""
Parse the main command-line options, returning the positional arguments.
"""
global OPTS
OPTS, args = OPTPARSE.parse_args()
OPTPARSE.show_global_opts = False
CFG.OPTS = OPTS
## It's tempting to load the configuration here. Don't do that. Some
## contexts will want to check that the command line was handled properly
## upstream before believing it for anything, such as executing arbitrary
## Python code.
return args
###--------------------------------------------------------------------------
### 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'):
if OPTS: return
OPTPARSE.context = ctx
args = parse_options()
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():
with OUT.redirect_to(O.FileOutput()):
args = parse_options()
if not args or args[0] != 'userv':
raise U.ExpectedError, (500, 'missing userv token')
CONF.loadconfig(OPTS.config)
try: CU.set_user(ENV['USERV_USER'])
except KeyError: raise ExpectedError, (500, 'USERV_USER unset')
OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args[1:])
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."""
args = parse_options()
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():
with OUT.redirect_to(O.FileOutput()):
cmd = ssh_setup()
CU.set_user(ENV['CHPWD_SSH_USER'])
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:
with OUT.redirect_to(O.RemoteOutput()):
cmd = ssh_setup()
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():
with OUT.redirect_to(O.FileOutput()):
args = parse_options()
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'
OPTPARSE.show_global_opts = True
OPTPARSE.dispatch(ctx, args)
###----- That's all, folks --------------------------------------------------