mumble/scripts/phpBB3auth.py

726 lines
26 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8
#
# phpBB3auth.py - Sample script to demonstrate authentication against
# an existing phpBB3 forum database.
#
# Requirements:
# * python >=2.4 and the following python modules:
# * ice-python
# * PIL >=1.1.5 (only if avatar import is enabled)
# * MySQLdb
# * daemon (when run as a daemon)
import sys
import Ice
import thread
import logging
import ConfigParser
from logging import (debug,
info,
warning,
error,
critical,
getLogger)
from optparse import OptionParser
try:
from hashlib import md5
except ImportError: # python 2.4 compat
from md5 import md5
def x2bool(s):
"""Helper function to convert strings from the config to bool"""
if isinstance(s, bool):
return s
elif isinstance(s, basestring):
return s.lower() in ['1', 'true']
raise ValueError()
#
#--- Default configuration values
#
cfgfile = 'phpBB3auth.ini'
user_texture_resolution = (600,60)
default = {'database':(('lib', str, 'MySQLdb'),
('name', str, 'phpbb3'),
('user', str, 'phpbb3'),
('password', str, 'secret'),
('prefix', str, 'phpbb_'),
('host', str, '127.0.0.1'),
('port', int, 3306)),
'user':(('id_offset', int, 1000000000),
('avatar_enable', x2bool, False),
('avatar_path', str, 'http://localhost/phpBB3/download.php?avatar='),
('avatar_username_enable', x2bool, True),
('avatar_username_font', str, 'verdana.ttf'),
('avatar_username_fontsize', int, 30),
('avatar_username_x', int, 65),
('avatar_username_y', int, 10),
('avatar_username_fill', str, '#FF0000')),
'ice':(('host', str, '127.0.0.1'),
('port', int, 6502),
('slice', str, 'Murmur.ice')),
'iceraw':None,
'murmur':(('servers', lambda x:map(int, x.split(',')), []),),
'glacier':(('enabled', x2bool, False),
('user', str, 'phpBB3auth'),
('password', str, 'secret'),
('host', str, 'localhost'),
('port', int, '4063')),
'log':(('level', int, logging.DEBUG),
('file', str, 'phpBB3auth.log'))}
#
#--- Helper classes
#
class config(object):
"""
Small abstraction for config loading
"""
def __init__(self, filename = None, default = None):
if not filename or not default: return
cfg = ConfigParser.ConfigParser()
cfg.optionxform = str
cfg.read(filename)
for h,v in default.iteritems():
if not v:
# Output this whole section as a list of raw key/value tuples
try:
self.__dict__[h] = cfg.items(h)
except ConfigParser.NoSectionError:
self.__dict__[h] = []
else:
self.__dict__[h] = config()
for name, conv, vdefault in v:
try:
self.__dict__[h].__dict__[name] = conv(cfg.get(h, name))
except (ValueError, ConfigParser.NoSectionError, ConfigParser.NoOptionError):
self.__dict__[h].__dict__[name] = vdefault
class threadDbException(Exception): pass
class threadDB(object):
"""
Small abstraction to handle database connections for multiple
threads
"""
db_connections = {}
def connection(cls):
tid = thread.get_ident()
try:
con = cls.db_connections[tid]
except:
info('Connecting to database server (%s %s:%d %s) for thread %d',
cfg.database.lib, cfg.database.host, cfg.database.port, cfg.database.name, tid)
try:
con = db.connect(host = cfg.database.host,
port = cfg.database.port,
user = cfg.database.user,
passwd = cfg.database.password,
db = cfg.database.name,
charset = 'utf8')
except db.Error, e:
error('Could not connect to database: %s', str(e))
raise threadDbException()
cls.db_connections[tid] = con
return con
connection = classmethod(connection)
def cursor(cls):
return cls.connection().cursor()
cursor = classmethod(cursor)
def execute(cls, *args, **kwargs):
c = cls.cursor()
try:
c.execute(*args, **kwargs)
except db.OperationalError, e:
error('Database operational error %d: %s', e.args[0], e.args[1])
c.close()
cls.invalidate_connection()
raise threadDbException()
return c
execute = classmethod(execute)
def invalidate_connection(cls):
tid = thread.get_ident()
con = cls.db_connections.pop(tid, None)
if con:
debug('Invalidate connection to database for thread %d', tid)
con.close()
invalidate_connection = classmethod(invalidate_connection)
def disconnect(cls):
while cls.db_connections:
tid, con = cls.db_connections.popitem()
debug('Close database connection for thread %d', tid)
con.close()
disconnect = classmethod(disconnect)
def do_main_program():
#
#--- Authenticator implementation
# All of this has to go in here so we can correctly daemonize the tool
# without loosing the file descriptors opened by the Ice module
Ice.loadSlice(cfg.ice.slice)
import Murmur
class phpBBauthenticatorApp(Ice.Application):
def run(self, args):
self.shutdownOnInterrupt()
if not self.initializeIceConnection():
return 1
# Serve till we are stopped
self.communicator().waitForShutdown()
if self.interrupted():
warning('Caught interrupt, shutting down')
threadDB.disconnect()
return 0
def initializeIceConnection(self):
"""
Establishes the two-way Ice connection and adds the authenticator to the
configured servers
"""
ice = self.communicator()
if cfg.glacier.enabled:
#info('Connecting to Glacier2 server (%s:%d)', glacier_host, glacier_port)
error('Glacier support not implemented yet')
#TODO: Implement this
info('Connecting to Ice server (%s:%d)', cfg.ice.host, cfg.ice.port)
base = ice.stringToProxy('Meta:tcp -h %s -p %d' % (cfg.ice.host, cfg.ice.port))
try:
meta = Murmur.MetaPrx.checkedCast(base)
except Ice.LocalException, e:
error('Could not connect to Ice server, error %d: %s', e.error, str(e).replace('\n', ' '))
return False
adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', 'tcp -h %s' % cfg.ice.host)
adapter.activate()
for server in meta.getBootedServers():
if not cfg.murmur.servers or server.id() in cfg.murmur.servers:
info('Setting authenticator for server %d', server.id())
authprx = adapter.addWithUUID(phpBBauthenticator(server, adapter))
auth = Murmur.ServerUpdatingAuthenticatorPrx.uncheckedCast(authprx)
server.setAuthenticator(auth)
return True
class phpBBauthenticator(Murmur.ServerUpdatingAuthenticator):
texture_cache = {}
def __init__(self, server, adapter):
Murmur.ServerUpdatingAuthenticator.__init__(self)
self.server = server
if cfg.user.avatar_enable and cfg.user.avatar_username_enable:
# Load font
try:
self.font = ImageFont.truetype(cfg.user.avatar_username_font, cfg.user.avatar_username_fontsize)
except IOError, e:
error("Could not load font for username texture overlay from '%s': %s", cfg.user.avatar_username_font, e)
self.font = None
else:
self.font = None
def authenticate(self, name, pw, certlist, certhash, strong, current = None):
"""
This function is called to authenticate a user
"""
# Search for the user in the database
FALL_THROUGH = -2
AUTH_REFUSED = -1
if name == 'SuperUser':
debug('Forced fall through for SuperUser')
return (FALL_THROUGH, None, None)
try:
sql = 'SELECT user_id, user_password, user_type, username FROM %susers WHERE (user_type = 0 OR user_type = 3) AND LOWER(username) = LOWER(%%s)' % cfg.database.prefix
cur = threadDB.execute(sql, name)
except threadDbException:
return (FALL_THROUGH, None, None)
res = cur.fetchone()
cur.close()
if not res:
info('Fall through for unknown user "%s"', name)
return (FALL_THROUGH, None, None)
uid, upw, utp, unm = res
if phpbb_check_hash(pw, upw):
# Authenticated, fetch group memberships
try:
sql = 'SELECT group_name FROM %suser_group JOIN %sgroups USING (group_id) WHERE user_id = %%s' % (cfg.database.prefix, cfg.database.prefix)
cur = threadDB.execute(sql, uid)
except threadDbException:
return (FALL_THROUGH, None, None)
res = cur.fetchall()
cur.close()
if res:
res = [a[0] for a in res]
info('User authenticated: "%s" (%d)', name, uid + cfg.user.id_offset)
debug('Group memberships: %s', str(res))
return (uid + cfg.user.id_offset, name, res)
info('Failed authentication attempt for user: "%s" (%d)', name, uid + cfg.user.id_offset)
return (AUTH_REFUSED, None, None)
def getInfo(self, id, current = None):
"""
Gets called to fetch user specific information
"""
# We do not expose any additional information so always fall through
debug('getInfo for %d -> denied', id)
return (False, None)
def nameToId(self, name, current = None):
"""
Gets called to get the id for a given username
"""
FALL_THROUGH = -2
if name == 'SuperUser':
debug('nameToId SuperUser -> forced fall through')
return FALL_THROUGH
try:
sql = 'SELECT user_id FROM %susers WHERE (user_type = 0 OR user_type = 3) AND LOWER(username) = LOWER(%%s)' % cfg.database.prefix
cur = threadDB.execute(sql, name)
except threadDbException:
return FALL_THROUGH
res = cur.fetchone()
cur.close()
if not res:
debug('nameToId %s -> ?', name)
return FALL_THROUGH
debug('nameToId %s -> %d', name, (res[0] + cfg.user.id_offset))
return res[0] + cfg.user.id_offset
def idToName(self, id, current = None):
"""
Gets called to get the username for a given id
"""
FALL_THROUGH = ""
# Make sure the ID is in our range and transform it to the actual phpBB3 user id
if id < cfg.user.id_offset:
return FALL_THROUGH
bbid = id - cfg.user.id_offset
# Fetch the user from the database
try:
sql = 'SELECT username FROM %susers WHERE (user_type = 0 OR user_type = 3) AND user_id = %%s' % cfg.database.prefix
cur = threadDB.execute(sql, bbid)
except threadDbException:
return FALL_THROUGH
res = cur.fetchone()
cur.close()
if res:
if res[0] == 'SuperUser':
debug('idToName %d -> "SuperUser" catched')
return FALL_THROUGH
debug('idToName %d -> "%s"', id, res[0])
return res[0]
debug('idToName %d -> ?', id)
return FALL_THROUGH
def idToTexture(self, id, current = None):
"""
Gets called to get the corresponding texture for a user
"""
FALL_THROUGH = ""
debug('idToTexture for %d', id)
if id < cfg.user.id_offset or not cfg.user.avatar_enable:
debug('idToTexture %d -> fall through', id)
return FALL_THROUGH
# Otherwise get the users texture from phpBB3
bbid = id - cfg.user.id_offset
try:
sql = 'SELECT username, user_avatar, user_avatar_type FROM %susers WHERE (user_type = 0 OR user_type = 3) AND user_id = %%s' % cfg.database.prefix
cur = threadDB.execute(sql, bbid)
except threadDbException:
return FALL_THROUGH
res = cur.fetchone()
cur.close()
if not res:
debug('idToTexture %d -> user unknown, fall through', id)
return FALL_THROUGH
username, avatar_file, avatar_type = res
if avatar_type != 1 and avatar_type != 2:
debug('idToTexture %d -> no texture available for this user (%d), fall through', id, avatar_type)
return FALL_THROUGH
if avatar_file in self.texture_cache:
return self.texture_cache[avatar_file]
if avatar_type == 1:
url = cfg.user.avatar_path + avatar_file
else:
url = avatar_file
try:
handle = urllib2.urlopen(url)
file = StringIO.StringIO(handle.read())
handle.close()
except urllib2.URLError, e:
warning('Image download for "%s" (%d) failed: %s', url, id, str(e))
return FALL_THROUGH
try:
# Load image and scale it
img = Image.open(file).convert("RGBA")
img.thumbnail((user_texture_resolution[0],user_texture_resolution[1]), Image.ANTIALIAS)
img = img.transform(user_texture_resolution,
Image.EXTENT,
(0, 0, user_texture_resolution[0], user_texture_resolution[1]))
if cfg.user.avatar_username_enable and self.font:
# Insert user name into picture
draw = ImageDraw.Draw(img)
draw.text((cfg.user.avatar_username_x, cfg.user.avatar_username_y),
username,
fill = cfg.user.avatar_username_fill,
font = self.font)
r,g,b,a = img.split()
raw = Image.merge('RGBA', (b, g, r, a)).tostring()
comp = compress(raw)
res = pack('>L', len(raw)) + comp
except Exception, e:
warning('Image manipulation for "%s" (%d) failed', url, id)
debug(e)
return FALL_THROUGH
self.texture_cache[avatar_file] = res
return res
def registerUser(self, name, current = None):
"""
Gets called when the server is asked to register a user.
"""
FALL_THROUGH = -2
debug('registerUser "%s" -> fall through', name)
return FALL_THROUGH
def unregisterUser(self, id, current = None):
"""
Gets called when the server is asked to unregister a user.
"""
FALL_THROUGH = -1
# Return -1 to fall through to internal server database, we will not modify the phpbb3 database
# but we can make murmur delete all additional information it got this way.
debug('unregisterUser %d -> fall through', id)
return FALL_THROUGH
def getRegisteredUsers(self, filter, current = None):
"""
Returns a list of usernames in the phpBB3 database which contain
filter as a substring.
"""
if not filter:
filter = '%'
try:
sql = 'SELECT user_id, username FROM %susers WHERE (user_type = 0 OR user_type = 3) AND username LIKE %%s' % cfg.database.prefix
cur = threadDB.execute(sql, filter)
except threadDbException:
return {}
res = cur.fetchall()
cur.close()
if not res:
debug('getRegisteredUsers -> empty list for filter "%s"', filter)
return {}
debug ('getRegisteredUsers -> %d results for filter "%s"', len(res), filter)
return dict([(a + cfg.user.id_offset, b) for a,b in res])
def setInfo(self, id, info, current = None):
"""
Gets called when the server is supposed to save additional information
about a user to his database
"""
FALL_THROUGH = -1
# Return -1 to fall through to the internal server handler. We must not modify
# the phpBB3 database so the additional information is stored in murmurs database
debug('setInfo %d -> fall through', id)
return FALL_THROUGH
def setTexture(self, id, texture, current = None):
"""
Gets called when the server is asked to update the user texture of a user
"""
FAILED = 0
FALL_THROUGH = -1
if id < cfg.user.id_offset:
debug('setTexture %d -> fall through', id)
return FALL_THROUGH
if cfg.user.avatar_enable:
# Report a fail (0) as we will not update the avatar in the phpBB3 database.
debug('setTexture %d -> failed', id)
return FAILED
# If we don't use textures from phpbb we let mumble save it
debug('setTexture %d -> fall through', id)
return FALL_THROUGH
class CustomLogger(Ice.Logger):
"""
Logger implementation to pipe Ice log messages into
out own log
"""
def __init__(self):
Ice.Logger.__init__(self)
self._log = getLogger("Ice")
def _print(self, message):
self._log.info(message)
def trace(self, category, message):
self._log.debug("Trace %s: %s", category, message)
def warning(self, message):
self._log.warning(message)
def error(self, message):
self._log.error(message)
#
#--- Start of authenticator
#
info('Starting phpBB3 mumble authenticator')
initdata = Ice.InitializationData()
initdata.properties = Ice.createProperties([], initdata.properties)
for prop, val in cfg.iceraw:
initdata.properties.setProperty(prop, val)
initdata.logger = CustomLogger()
app = phpBBauthenticatorApp()
state = app.main(sys.argv[:1], initData = initdata)
info('Shutdown complete')
#
#--- Python implementation of the phpBB3 check hash function (salted md5)
#
def _hash_encode64(sinput, count, itoa64):
output = ''
i = 0
while True:
value = ord(sinput[i])
i += 1
output += itoa64[value & 0x3f]
if i < count:
value |= (ord(sinput[i]) << 8)
output += itoa64[(value >> 6) & 0x3f]
if i >= count:
break
i += 1
if i < count:
value |= (ord(sinput[i]) << 16)
output += itoa64[(value >> 12) & 0x3f]
if i >= count:
break
i = i + 1
output += itoa64[(value >> 18) & 0x3f]
if i >= count:
break
return output
def _hash_crypt_private(password, settings, itoa64):
output = '*'
if settings[0:3] != '$H$':
return output
try:
count_log2 = itoa64.index(settings[3])
except ValueError:
return output
if (count_log2 < 7) or (count_log2 > 30):
return output
count = 1 << count_log2
salt = settings[4:12]
if len(salt) != 8:
return output
hash = md5(salt + password).digest()
while True:
hash = md5(hash + password).digest()
count = count - 1
if count <= 0:
break
output = settings[0:12]
output += _hash_encode64(hash, 16, itoa64)
return output
def phpbb_check_hash(password, hash):
"""
Python implementation of the phpBB3 check hash function
"""
itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
if len(hash) == 34:
return _hash_crypt_private(password, hash, itoa64) == hash
return md5(password).hexdigest() == hash
#
#--- Start of program
#
if __name__ == '__main__':
# Parse commandline options
parser = OptionParser()
parser.add_option('-i', '--ini',
help = 'load configuration from INI', default = cfgfile)
parser.add_option('-v', '--verbose', action='store_true', dest = 'verbose',
help = 'verbose output [default]', default = True)
parser.add_option('-q', '--quiet', action='store_false', dest = 'verbose',
help = 'only error output')
parser.add_option('-d', '--daemon', action='store_true', dest = 'force_daemon',
help = 'run as daemon', default = False)
parser.add_option('-a', '--app', action='store_true', dest = 'force_app',
help = 'do not run as daemon', default = False)
(option, args) = parser.parse_args()
if option.force_daemon and option.force_app:
parser.print_help()
sys.exit(1)
# Load configuration
try:
cfg = config(option.ini, default)
except Exception, e:
print>>sys.stderr, 'Fatal error, could not load config file from "%s"' % cfgfile
sys.exit(1)
# Do conditional imports
if cfg.user.avatar_enable:
# If we use avatars we need PIL to manipulate it and some other stuff for working with them
try:
import Image
if cfg.user.avatar_username_enable:
import ImageFont
import ImageDraw
except ImportError, e:
print>>sys.stderr, 'Error, could not import PIL library, '\
'please install the missing dependency and restart the authenticator'
sys.exit(1)
import urllib2
import StringIO
from zlib import compress
from struct import pack
try:
db = __import__(cfg.database.lib)
except ImportError, e:
print>>sys.stderr, 'Fatal error, could not import database library "%s", '\
'please install the missing dependency and restart the authenticator' % cfg.database.lib
sys.exit(1)
# Initialize logger
if cfg.log.file:
try:
logfile = open(cfg.log.file, 'a')
except IOError, e:
print>>sys.stderr, 'Fatal error, could not open logfile "%s"' % cfg.log.file
sys.exit(1)
else:
logfile = logging.sys.stderr
if option.verbose:
level = cfg.log.level
else:
level = logging.ERROR
logging.basicConfig(level = level,
format='%(asctime)s %(levelname)s %(message)s',
stream = logfile)
# As the default try to run as daemon. Silently degrade to running as a normal application if this fails
# unless the user explicitly defined what he expected with the -a / -d parameter.
try:
if option.force_app:
raise ImportError # Pretend that we couldn't import the daemon lib
import daemon
except ImportError:
if option.force_daemon:
print>>sys.stderr, 'Fatal error, could not daemonize process due to missing "daemon" library, ' \
'please install the missing dependency and restart the authenticator'
sys.exit(1)
do_main_program()
else:
context = daemon.DaemonContext(working_directory = sys.path[0],
stderr = logfile)
context.__enter__()
try:
do_main_program()
finally:
context.__exit__(None, None, None)