diff options
author | Stefan Hacker <dd0t@users.sourceforge.net> | 2010-01-29 20:01:23 +0300 |
---|---|---|
committer | Stefan Hacker <dd0t@users.sourceforge.net> | 2010-01-29 20:02:47 +0300 |
commit | d30713ccb997df44dc306f065b780aef3e49afed (patch) | |
tree | ecb9bbf7b8ff15366012ab0f971fc87573532e11 /scripts | |
parent | 6f637c6f038bc7b4e9df9c03ca9a93611947c286 (diff) |
Added Simple Machine Forums (SMF) authenticator to scripts folder
Diffstat (limited to 'scripts')
-rw-r--r-- | scripts/phpBB3auth.ini | 2 | ||||
-rw-r--r-- | scripts/phpBB3auth.py | 12 | ||||
-rw-r--r-- | scripts/smfauth.ini | 49 | ||||
-rw-r--r-- | scripts/smfauth.py | 672 |
4 files changed, 728 insertions, 7 deletions
diff --git a/scripts/phpBB3auth.ini b/scripts/phpBB3auth.ini index 7df726cb3..d33a295b2 100644 --- a/scripts/phpBB3auth.ini +++ b/scripts/phpBB3auth.ini @@ -15,7 +15,7 @@ port = 3306 id_offset = 1000000000 ;If enabled avatars are automatically set as user textures avatar_enable = False -avatar_path = http://host.tld/phpBB3/download/file.php?avatar= +avatar_path = http://localhost/phpBB3/download/file.php?avatar= ;If avatar fetching is enabled and the following options are configured ;the username will be overlayed over the avatar from the board. diff --git a/scripts/phpBB3auth.py b/scripts/phpBB3auth.py index 182180a63..2fb7bc45e 100644 --- a/scripts/phpBB3auth.py +++ b/scripts/phpBB3auth.py @@ -6,11 +6,11 @@ # an existing phpBB3 forum database. # # Requirements: -# * python >=2.4 -# * ice-python -# * PIL >=1.1.5 (only if avatar import is enabled) -# * MySQLdb -# * daemon (when run as a daemon) +# * 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 @@ -53,7 +53,7 @@ default = {'database':(('lib', str, 'MySQLdb'), 'user':(('id_offset', int, 1000000000), ('avatar_enable', x2bool, False), - ('avatar_path', str, 'http://forum.tld/phpBB3/download.php?avatar='), + ('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), diff --git a/scripts/smfauth.ini b/scripts/smfauth.ini new file mode 100644 index 000000000..59bef7390 --- /dev/null +++ b/scripts/smfauth.ini @@ -0,0 +1,49 @@ +;Database configuration +[database] +;Only tested with MySQL at the moment +lib = MySQLdb +name = smf +user = smf +password = secret +prefix = smf_ +host = 127.0.0.1 +port = 3306 + +;Forum information +[forum] +url = http://localhost/smf/ +;Player configuration +[user] +;If you do not already know what it is just leave it as it is +id_offset = 1000000000 +;If enabled avatars are automatically set as user textures +avatar_enable = False + +;If avatar fetching is enabled and the following options are configured +;the username will be overlayed over the avatar from the board. +;The font can be any truetype font (default is verdana), the x,y variables +;define the offset and fill the color used to draw the text +avatar_username_enable = True +avatar_username_font = verdana.ttf +avatar_username_fontsize = 30 +avatar_username_x = 65 +avatar_username_y = 10 +avatar_username_fill = #FFCC01 + + +;Ice configuration +[ice] +host = 127.0.0.1 +port = 6502 +slice = Murmur.ice + +;Murmur configuration +[murmur] +;List of virtual server IDs, empty = all +servers = + +;Logging configuration +[log] +; Available loglevels: 10 = DEBUG (default) | 20 = INFO | 30 = WARNING | 40 = ERROR +level = +file = smfauth.log
\ No newline at end of file diff --git a/scripts/smfauth.py b/scripts/smfauth.py new file mode 100644 index 000000000..9cf74af62 --- /dev/null +++ b/scripts/smfauth.py @@ -0,0 +1,672 @@ +#!/usr/bin/env python +# -*- coding: utf-8 + +# +# smfauth.py - Sample script to demonstrate authentication against +# an existing simple machine forums 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 sha1 +except ImportError: # python 2.4 compat + from sha import sha as sha1 + +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 = 'smfauth.ini' +user_texture_resolution = (600,60) +default = {'database':(('lib', str, 'MySQLdb'), + ('name', str, 'smf'), + ('user', str, 'smf'), + ('password', str, 'secret'), + ('prefix', str, 'smf_'), + ('host', str, '127.0.0.1'), + ('port', int, 3306)), + 'forum':(('url', str, 'http://localhost/smf/'),), + + 'user':(('id_offset', int, 1000000000), + ('avatar_enable', x2bool, False), + ('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, 'smf'), + ('password', str, 'secret'), + ('host', str, 'localhost'), + ('port', int, '4063')), + + 'log':(('level', int, logging.DEBUG), + ('file', str, 'smfauth.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 smfauthenticatorApp(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(smfauthenticator(server, adapter)) + auth = Murmur.ServerUpdatingAuthenticatorPrx.uncheckedCast(authprx) + server.setAuthenticator(auth) + return True + + class smfauthenticator(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 ID_MEMBER, passwd, ID_GROUP, memberName, additionalGroups, is_activated FROM %smembers WHERE LOWER(memberName) = 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, ug, unm, uag, activated = res + + if activated == 1 and smf_check_hash(pw, upw, unm): + # Authenticated, fetch group memberships + try: + sql = 'SELECT groupName FROM %smembergroups WHERE ID_GROUP IN (%s)' % (cfg.database.prefix, str(ug) if not uag else str(ug)+','+uag) + cur = threadDB.execute(sql) + 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 ID_MEMBER FROM %smembers WHERE LOWER(memberName) = 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 smf 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 memberName FROM %smembers WHERE ID_MEMBER = %%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 smf + bbid = id - cfg.user.id_offset + try: + sql = 'SELECT memberName, avatar FROM %smembers WHERE ID_MEMBER = %%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 = res + + if not avatar: + # Either the user has none or it is in the attachments, check there + try: + sql = 'SELECT file_hash FROM %sattachments WHERE ID_MEMBER = %%s' % cfg.database.prefix + cur = threadDB.execute(sql, bbid) + except threadDbException: + return FALL_THROUGH + + res = cur.fetchone() + cur.close() + if not res: + # No uploaded avatar found, seems like the user didn't set one + debug('idToTexture %d -> no texture available for this user, fall through', id) + return FALL_THROUGH + + avatar_file = cfg.forum.url + 'index.php?action=dlattach;attach=%d;type=avatar' % bbid + elif "://" in avatar: + # ...or it is a external link + avatar_file = avatar + else: + # Or it is saved locally in the avatar folder + avatar_file = cfg.forum.url + 'avatars/' + avatar + + if avatar_file in self.texture_cache: + return self.texture_cache[avatar_file] + try: + handle = urllib2.urlopen(avatar_file) + file = StringIO.StringIO(handle.read()) + handle.close() + except urllib2.URLError, e: + warning('Image download for "%s" (%d) failed: %s', avatar_file, 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 smf 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 smf database which contain + filter as a substring. + """ + + if not filter: + filter = '%' + + try: + sql = 'SELECT ID_MEMBER, memberName FROM %smembers WHERE is_activated = 1 AND memberName 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 smf 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 smf database. + debug('setTexture %d -> failed', id) + return FAILED + + # If we don't use textures from smf 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 smf 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 = smfauthenticatorApp() + state = app.main(sys.argv[:1], initData = initdata) + info('Shutdown complete') + + + +# +#--- Python implementation of the smf check hash function +# +def smf_check_hash(password, hash, username): + """ + Python implementation of the smf check hash function + """ + return sha1(username.lower() + password.lower()).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) |