| 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 service as S |
| 42 | import subcommand as SC |
| 43 | import util as U |
| 44 | |
| 45 | for i in ['admin', 'cgi', 'remote', 'user']: |
| 46 | __import__('cmd-' + i) |
| 47 | |
| 48 | ###-------------------------------------------------------------------------- |
| 49 | ### Parsing command-line options. |
| 50 | |
| 51 | ## Command-line options parser. |
| 52 | OPTPARSE = SC.SubcommandOptionParser( |
| 53 | usage = '%prog SUBCOMMAND [ARGS ...]', |
| 54 | version = '%%prog, verion %s' % VERSION, |
| 55 | contexts = ['admin', 'userv', 'remote', 'cgi', 'cgi-query', 'cgi-noauth'], |
| 56 | commands = SC.COMMANDS, |
| 57 | description = """\ |
| 58 | Manage all of those annoying passwords. |
| 59 | |
| 60 | This is free software, and you can redistribute it and/or modify it |
| 61 | under the terms of the GNU Affero General Public License |
| 62 | <http://www.gnu.org/licenses/agpl-3.0.html>. For a `.tar.gz' file |
| 63 | of the source code, use the `source' command. |
| 64 | """) |
| 65 | |
| 66 | OPTS = None |
| 67 | |
| 68 | ## Set up the global options. |
| 69 | for short, long, props in [ |
| 70 | ('-c', '--context', { |
| 71 | 'metavar': 'CONTEXT', 'dest': 'context', 'default': None, |
| 72 | 'help': 'run commands with the given CONTEXT' }), |
| 73 | ('-f', '--config-file', { |
| 74 | 'metavar': 'FILE', 'dest': 'config', |
| 75 | 'default': ENV.get('CHPWD_CONFIG', |
| 76 | OS.path.join(HOME, 'chpwd.conf')), |
| 77 | 'help': 'read configuration from FILE.' }), |
| 78 | ('-s', '--ssl', { |
| 79 | 'dest': 'sslp', 'action': 'store_true', |
| 80 | 'help': 'pretend CGI connection is carried over SSL/TLS' }), |
| 81 | ('-u', '--user', { |
| 82 | 'metavar': 'USER', 'dest': 'user', 'default': None, |
| 83 | 'help': "impersonate USER, and default context to `userv'." })]: |
| 84 | OPTPARSE.add_option(short, long, **props) |
| 85 | |
| 86 | ###-------------------------------------------------------------------------- |
| 87 | ### CGI dispatch. |
| 88 | |
| 89 | ## The special variables, to be picked out by `cgiparse'. |
| 90 | CGI.SPECIAL['%act'] = None |
| 91 | CGI.SPECIAL['%nonce'] = None |
| 92 | CGI.SPECIAL['%user'] = None |
| 93 | |
| 94 | ## We don't want to parse arguments until we've settled on a context; but |
| 95 | ## issuing redirects in the early setup phase fails because we don't know |
| 96 | ## the script name. So package the setup here. |
| 97 | def cgi_setup(ctx = 'cgi-noauth'): |
| 98 | global OPTS |
| 99 | if OPTS: return |
| 100 | OPTPARSE.context = ctx |
| 101 | OPTS, args = OPTPARSE.parse_args() |
| 102 | if args: raise U.ExpectedError, (500, 'Unexpected arguments to CGI') |
| 103 | CONF.loadconfig(OPTS.config) |
| 104 | D.opendb() |
| 105 | |
| 106 | def dispatch_cgi(): |
| 107 | """Examine the CGI request and invoke the appropriate command.""" |
| 108 | |
| 109 | ## Start by picking apart the request. |
| 110 | CGI.cgiparse() |
| 111 | |
| 112 | ## We'll be taking items off the trailing path. |
| 113 | i, np = 0, len(CGI.PATH) |
| 114 | |
| 115 | ## Sometimes, we want to run several actions out of the same form, so the |
| 116 | ## subcommand name needs to be in the query string. We use the special |
| 117 | ## variable `%act' for this. If it's not set, then we use the first elment |
| 118 | ## of the path. |
| 119 | act = CGI.SPECIAL['%act'] |
| 120 | if act is None: |
| 121 | if i >= np: |
| 122 | cgi_setup() |
| 123 | CGI.redirect(CGI.action('login')) |
| 124 | return |
| 125 | act = CGI.PATH[i] |
| 126 | i += 1 |
| 127 | |
| 128 | ## Figure out which context we're meant to be operating in, according to |
| 129 | ## the requested action. Unknown actions result in an error here; known |
| 130 | ## actions where we don't have enough authorization send the user back to |
| 131 | ## the login page. |
| 132 | for ctx in ['cgi-noauth', 'cgi-query', 'cgi']: |
| 133 | try: |
| 134 | c = OPTPARSE.lookup_subcommand(act, exactp = True, context = ctx) |
| 135 | except U.ExpectedError, e: |
| 136 | if e.code != 404: raise |
| 137 | else: |
| 138 | break |
| 139 | else: |
| 140 | raise e |
| 141 | |
| 142 | ## Parse the command line, and load configuration. |
| 143 | cgi_setup(ctx) |
| 144 | |
| 145 | ## Check whether we have enough authorization. There's always enough for |
| 146 | ## `cgi-noauth'. |
| 147 | if ctx != 'cgi-noauth': |
| 148 | |
| 149 | ## The next part of the URL should be the user name, so that caches don't |
| 150 | ## cross things over. |
| 151 | expuser = CGI.SPECIAL['%user'] |
| 152 | if expuser is None: |
| 153 | if i >= np: raise U.ExpectedError, (404, 'Missing user name') |
| 154 | expuser = CGI.PATH[i] |
| 155 | i += 1 |
| 156 | |
| 157 | ## If there's no token cookie, then we have to bail. |
| 158 | try: token = CGI.COOKIE['chpwd-token'] |
| 159 | except KeyError: |
| 160 | CGI.redirect(CGI.action('login', why = 'NOAUTH')) |
| 161 | return |
| 162 | |
| 163 | ## If we only want read-only access, then the cookie is good enough. |
| 164 | ## Otherwise we must check that a nonce was supplied, and that it is |
| 165 | ## correct. |
| 166 | if ctx == 'cgi-query': |
| 167 | nonce = None |
| 168 | else: |
| 169 | nonce = CGI.SPECIAL['%nonce'] |
| 170 | if not nonce: |
| 171 | CGI.redirect(CGI.action('login', why = 'NONONCE')) |
| 172 | return |
| 173 | |
| 174 | ## Verify the token and nonce. |
| 175 | try: |
| 176 | CU.USER = HA.check_auth(token, nonce) |
| 177 | except HA.AuthenticationFailed, e: |
| 178 | CGI.redirect(CGI.action('login', why = e.why)) |
| 179 | return |
| 180 | if CU.USER != expuser: raise U.ExpectedError, (401, 'User mismatch') |
| 181 | CGI.STATE.kw['user'] = CU.USER |
| 182 | |
| 183 | ## Invoke the subcommand handler. |
| 184 | c.cgi(CGI.PARAM, CGI.PATH[i:]) |
| 185 | |
| 186 | ###-------------------------------------------------------------------------- |
| 187 | ### Main dispatch. |
| 188 | |
| 189 | @CTX.contextmanager |
| 190 | def cli_errors(): |
| 191 | """Catch expected errors and report them in the traditional Unix style.""" |
| 192 | try: |
| 193 | yield None |
| 194 | except U.ExpectedError, e: |
| 195 | SYS.stderr.write('%s: %s\n' % (OS.path.basename(SYS.argv[0]), e.msg)) |
| 196 | if 400 <= e.code < 500: SYS.exit(1) |
| 197 | else: SYS.exit(2) |
| 198 | |
| 199 | ### Main dispatch. |
| 200 | |
| 201 | if __name__ == '__main__': |
| 202 | |
| 203 | if 'REQUEST_METHOD' in ENV: |
| 204 | ## This looks like a CGI request. The heavy lifting for authentication |
| 205 | ## over HTTP is done in `dispatch_cgi'. |
| 206 | |
| 207 | with OUT.redirect_to(CGI.HTTPOutput()): |
| 208 | with CGI.cgi_errors(cgi_setup): dispatch_cgi() |
| 209 | |
| 210 | elif 'USERV_SERVICE' in ENV: |
| 211 | ## This is a Userv request. The caller's user name is helpfully in the |
| 212 | ## `USERV_USER' environment variable. |
| 213 | |
| 214 | with cli_errors(): |
| 215 | OPTS, args = OPTPARSE.parse_args() |
| 216 | CONF.loadconfig(OPTS.config) |
| 217 | try: CU.set_user(ENV['USERV_USER']) |
| 218 | except KeyError: raise ExpectedError, (500, 'USERV_USER unset') |
| 219 | with OUT.redirect_to(O.FileOutput()): |
| 220 | OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args) |
| 221 | |
| 222 | elif 'SSH_ORIGINAL_COMMAND' in ENV: |
| 223 | ## This looks like an SSH request; but we present two different |
| 224 | ## interfaces over SSH. We must distinguish them -- carefully: they have |
| 225 | ## very different error-reporting conventions. |
| 226 | |
| 227 | def ssh_setup(): |
| 228 | """Extract and parse the client's request from where SSH left it.""" |
| 229 | global OPTS |
| 230 | OPTS, args = OPTPARSE.parse_args() |
| 231 | CONF.loadconfig(OPTS.config) |
| 232 | cmd = SL.split(ENV['SSH_ORIGINAL_COMMAND']) |
| 233 | if args: raise U.ExpectedError, (500, 'Unexpected arguments via SSH') |
| 234 | return cmd |
| 235 | |
| 236 | if 'CHPWD_SSH_USER' in ENV: |
| 237 | ## Setting `CHPWD_SSH_USER' to a user name is the administrator's way |
| 238 | ## of telling us that this is a user request, so treat it like Userv. |
| 239 | |
| 240 | with cli_errors(): |
| 241 | cmd = ssh_setup() |
| 242 | CU.set_user(ENV['CHPWD_SSH_USER']) |
| 243 | S.SERVICES['master'].find(CU.USER) |
| 244 | with OUT.redirect_to(O.FileOutput()): |
| 245 | OPTPARSE.dispatch('userv', cmd) |
| 246 | |
| 247 | elif 'CHPWD_SSH_MASTER' in ENV: |
| 248 | ## Setting `CHPWD_SSH_MASTER' to anything tells us that the client is |
| 249 | ## making a remote-service request. We must turn on the protocol |
| 250 | ## decoration machinery, but don't need to -- mustn't, indeed -- set up |
| 251 | ## a user. |
| 252 | |
| 253 | try: |
| 254 | cmd = ssh_setup() |
| 255 | with OUT.redirect_to(O.RemoteOutput()): |
| 256 | OPTPARSE.dispatch('remote', map(CGI.urldecode, cmd)) |
| 257 | except U.ExpectedError, e: |
| 258 | print 'ERR', e.code, e.msg |
| 259 | else: |
| 260 | print 'OK' |
| 261 | |
| 262 | else: |
| 263 | ## There's probably some strange botch in the `.ssh/authorized_keys' |
| 264 | ## file, but we can't do much about it from here. |
| 265 | |
| 266 | with cli_errors(): |
| 267 | raise U.ExpectedError, (400, "Unabled to determine SSH context") |
| 268 | |
| 269 | else: |
| 270 | ## Plain old command line, apparently. We default to administration |
| 271 | ## commands, but allow any kind, since this is useful for debugging, and |
| 272 | ## this isn't a security problem since our caller is just as privileged |
| 273 | ## as we are. |
| 274 | |
| 275 | with cli_errors(): |
| 276 | OPTS, args = OPTPARSE.parse_args() |
| 277 | CONF.loadconfig(OPTS.config) |
| 278 | CGI.SSLP = OPTS.sslp |
| 279 | ctx = OPTS.context |
| 280 | if OPTS.user: |
| 281 | CU.set_user(OPTS.user) |
| 282 | if ctx is None: ctx = 'userv' |
| 283 | else: |
| 284 | D.opendb() |
| 285 | if ctx is None: ctx = 'admin' |
| 286 | with OUT.redirect_to(O.FileOutput()): |
| 287 | OPTPARSE.dispatch(ctx, args) |
| 288 | |
| 289 | ###----- That's all, folks -------------------------------------------------- |