Source

hg-textauth / textauth.py

Full commit
# textauth.py - HTTP basic authentication for Mercurial
#
# Copyright 2010 Henrik Stuart <hg@hstuart.dk>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

'''Provides HTTP basic authentication for Mercurial

The basic authentication uses a server-side stored password file.

Note that this extension is not compatible with htpasswd generated files as
they use a custom and non-standardised format. Instead we define our own
format below.

The configuration for this extension is read from the usual hgrc
configuration mechanism:

textauth
--------

Section that controls the behavior of the textauth extension.

Supported arguments:

``file``
    The path to the file containing authentication information. The
    contents of this file should be as described below.

file format
-----------

The format for the file is a list of ``username:password`` pairs, where the
password field encodes what type of password we are using. The following
would be a typical authentication file::

    user1:$plain$thisismypassword
    user2:$md5$31435008693ce6976f45dedc5532e2c1
    user3:$sha1$4b2c5a6d33c70caa171639d1e5a76a81f83c3cfb
    user4:$sha224$8607c28d53f2c41d101c3fdbe87210adbd0584c21fcd5ddd7237f039
    user5:$sha256$1da9133ab9dbd11d2937ec8d312e1e2569857059e73cc72df92e670928983ab5
    user6:$sha384$f2e4be47d9642bdd1d208f0738a55feca0c7bc42a445f1a0233c28260141ad6bb432810dafc7ffb5891f96ad65a3206f
    user7:$sha512$e9670bc9fb8d3a578736c31390205b1d9fef50adf941b3b349076d27e483e42b00c38998cac4370a362ffe5966d45012b95f8eb279b2cecfe1760cdf25d5ecca

This extension also doubles as a management interface to this file that can be
used through Mercurial::

    hg authedit

This will display usage details of working with it. Its interface mirrors that
of htpasswd somewhat, but its storage format is markedly different.

'''

import getpass
import base64
import re
import os.path

from mercurial.i18n import _
from mercurial.hgweb import common

def perform_authentication(hgweb, req, op):
    '''entry-point for hgweb to do HTTP basic authentication'''

    def fail(msg):
        raise common.ErrorResponse(common.HTTP_UNAUTHORIZED, msg,
                [('WWW-Authenticate', 'Basic Realm="mercurial"')])

    auth = req.env.get('HTTP_AUTHORIZATION')
    if not auth:
        fail('enter authorization')

    try:
        user, passwd = base64.b64decode(auth.split()[1]).split(':', 1)
        if not passwd:
            fail('invalid username or password')
    except (IndexError, TypeError):
        fail('invalid username or password')

    do_auth(hgweb, req, op, user, passwd)

def _decode_db_password(dbpasswd):
    regex = re.compile(r'^\$([^\$]+)\$(.*)$')
    m = regex.match(dbpasswd)
    if m:
        return m.group(1), m.group(2)
    return None, None

def do_auth(hgweb, req, op, user, passwd):
    fname = hgweb.config('textauth', 'file')
    fname = os.path.expanduser(fname)
    if not file or not os.path.exists(fname):
        raise common.ErrorResponse(common.HTTP_SERVER_ERROR,
                'no authentication file is specified on server')

    f = open(fname)
    try:
        db = [line.rstrip('\r\n').split(':', 1) for line in f]
        for dbuser, dbpasswd in db:
            if user == dbuser:
                algo, rdbpasswd = _decode_db_password(dbpasswd)
                if algo is None:
                    raise common.ErrorResponse(common.HTTP_SERVER_ERROR,
                            'invalid authentication file on server')
                algos = _gethashes()
                if algos[algo](passwd) == rdbpasswd:
                    req.env['REMOTE_USER'] = user
                    return
                else:
                    break
        raise common.ErrorResponse(common.HTTP_FORBIDDEN,
                'invalid username or password')
    finally:
        f.close()

def _gethashes():
    hashes = {'plain': lambda x: x}

    try:
        import hashlib
        hashes['md5'] = lambda x: hashlib.md5(x).hexdigest()
        hashes['sha1'] = lambda x: hashlib.sha1(x).hexdigest()
        hashes['sha224'] = lambda x: hashlib.sha224(x).hexdigest()
        hashes['sha256'] = lambda x: hashlib.sha256(x).hexdigest()
        hashes['sha384'] = lambda x: hashlib.sha384(x).hexdigest()
        hashes['sha512'] = lambda x: hashlib.sha512(x).hexdigest()
    except ImportError, AttributeError:
        import md5
        import sha
        hashes['md5'] = lambda x: md5.new(x).hexdigest()
        hashes['sha1'] = lambda x: sha.new(x).hexdigest()

    return hashes

def _map_read_db(authfile, func):
    fh = open(authfile, 'r')
    try:
        for l in fh:
            l = l.strip()
            if not l:
                continue
            func(l)
        pass
    finally:
        fh.close()

def _read_db(authfile):
    entries = {}
    _map_read_db(authfile, lambda l: entries.update((l.split(':', 1),)))
    return entries

def _read_db_ordered(authfile):
    entries = []
    _map_read_db(authfile, lambda l: entries.append(l.split(':', 1)))
    return entries

def authedit(ui, repo, username=None, **opts):
    '''Management routines for working with the authentication file

    Earlier versions of Python only support plain, md5 and sha1 hashes. Using
    a newer hash will result in an error being printed. Stored hashes using
    newer hashes than available will cause a 403 forbidden at the time of
    authentication.
    '''

    authfile = ui.config('textauth', 'file')
    if not authfile:
        ui.warn(_('no authentication file has been specified\n'))
        return 1

    authfile = os.path.expanduser(authfile)

    if opts.get('list'):
        users = []
        if os.path.exists(authfile):
            fh = open(authfile, 'r')
            try:
                for l in fh:
                    l = l.strip()
                    if not l:
                        continue
                    if ui.debugflag:
                        users.append(l)
                    else:
                        user, hash = l.split(':', 1)
                        users.append(user)
            finally:
                fh.close()

        if not users:
            ui.status(_('no users defined\n'))
        else:
            for user in users:
                ui.write('%s\n' % (user,))

        return 0

    if not os.path.exists(authfile):
        if opts.get('delete'):
            ui.warn(_('authentication file does not exist, cannot delete '
                'user\n'))
        elif not opts.get('create'):
            ui.warn(_('authentication file does not exist, use --create to '
                'create it\n'))

    if username is None:
        ui.warn(_('no username specified. aborting.\n'))
        return 1

    if opts.get('delete'):
        entries = _read_db_ordered(authfile)

        found = False
        for user, pw in entries:
            if user == username:
                found = True
                break

        if not found:
            ui.warn(_('no such user in file\n'))
            return 1

        fh = open(authfile, 'w')
        try:
            for user, pw in entries:
                if user != username:
                    fh.write('%s:%s\n' % (user, pw))
        finally:
            fh.close()

        return 0

    db = _read_db(authfile)
    if username in db:
        ui.warn(_('user already exists in authentication file. please remove '
            'before adding a new password.\n'))
        return 1

    hashes = _gethashes()

    hash = opts.get('hash')
    if not hash:
        if 'sha512' in hashes:
            hash = 'sha512'
        else:
            hash = 'sha1'
    else:
        hash = hash.lower()

    if hash not in hashes:
        ui.warn(_('unknown or unsupported hash specified (select one of: %s)'
            '.\n') % (', '.join(sorted(hashes))))
        return 1

    passwd = getpass.getpass('Enter password: ')
    verify = getpass.getpass('Verify password: ')

    if passwd != verify:
        ui.warn(_('passwords do not match\n'))
        return 1

    storepw = hashes[hash](passwd)

    fh = open(authfile, 'a')
    try:
        fh.write('%s:$%s$%s\n' % (username, hash, storepw))
    finally:
        fh.close()

def extsetup():
    common.permhooks.insert(0, perform_authentication)

cmdtable = {
        'authedit': (authedit, [
            ('h', 'hash', '', 
                _('the hash to use (plain, md5, sha1, sha224, sha256, '
                'sha384, sha512)\n')),
            ('c', 'create', False,
                _('create authentication file if it does not exist')),
            ('d', 'delete', False,
                _('delete the user entry rather than add it')),
            ('l', 'list', False,
                _('list the users approved for authentication')),
            ], _('[-h HASH] [-cdl] USERNAME')),
        }