diff --git a/CVoipAuth.ini b/CVoipAuth.ini index 53613bb..c08b13f 100644 --- a/CVoipAuth.ini +++ b/CVoipAuth.ini @@ -1,14 +1,3 @@ -;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 - ;Django configuration [django] ;If true, the authenticator will use Django to handle user authentication instead of the database diff --git a/CVoipAuth.py b/CVoipAuth.py index 3379e1c..9041911 100644 --- a/CVoipAuth.py +++ b/CVoipAuth.py @@ -1,41 +1,23 @@ import sys import Ice -import _thread -import urllib.request, urllib.error, urllib.parse import logging import configparser -import bcrypt - -from threading import Timer -from optparse import OptionParser -from logging import (debug, - info, - warning, - error, - critical, - exception, - getLogger) - -from hashlib import sha1 +from threading import Timer +from optparse import OptionParser +from logging import debug, info, warning, error, critical, exception, getLogger +# === Configuration Helpers === def x2bool(s): - """Helper function to convert strings from the config to bool""" + """Convert config strings to boolean""" if isinstance(s, bool): return s elif isinstance(s, str): return s.lower() in ['1', 'true'] raise ValueError() -# -#--- Default configuration values -# +# === Default Configuration === cfgfile = 'CVoipAuth.ini' -default = {'database':(('lib', str, 'MySQLdb'), - ('password', str, 'secret'), - ('host', str, '127.0.0.1'), - ('port', int, 3306)), - - 'django':(('enabled', x2bool, False), +default = {'django':(('enabled', x2bool, False), ('project', str, 'CVoipPanel'), ('settings', str, 'CVoipPanel.settings')), @@ -61,35 +43,29 @@ default = {'database':(('lib', str, 'MySQLdb'), 'log':(('level', int, logging.DEBUG), ('file', str, 'CVoipAuth.log'))} -# -#--- Helper classes -# +# === 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.items(): - if not v: - # Output this whole section as a list of raw key/value tuples + for section, values in default.items(): + if not values: try: - self.__dict__[h] = cfg.items(h) + self.__dict__[section] = cfg.items(section) except configparser.NoSectionError: - self.__dict__[h] = [] + self.__dict__[section] = [] else: - self.__dict__[h] = config() - for name, conv, vdefault in v: + self.__dict__[section] = config() + for name, conv, vdefault in values: try: - self.__dict__[h].__dict__[name] = conv(cfg.get(h, name)) + val = cfg.get(section, name) + self.__dict__[section].__dict__[name] = conv(val) except (ValueError, configparser.NoSectionError, configparser.NoOptionError): - self.__dict__[h].__dict__[name] = vdefault - + self.__dict__[section].__dict__[name] = vdefault + +# === HTML Entity Handling === def entity_decode(string): """ Python reverse implementation of php htmlspecialchars @@ -117,88 +93,8 @@ def entity_encode(string): for (s,t) in htmlspecialchars: ret = ret.replace(s, t) return ret - -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') - # Transactional engines like InnoDB initiate a transaction even - # on SELECTs-only. Thus, we auto-commit so smfauth gets recent data. - con.autocommit(True) - except db.Error as 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): - if "threadDB__retry_execution__" in kwargs: - # Have a magic keyword so we can call ourselves while preventing - # an infinite loop - del kwargs["threadDB__retry_execution__"] - retry = False - else: - retry = True - - c = cls.cursor() - try: - c.execute(*args, **kwargs) - except db.OperationalError as e: - error('Database operational error %d: %s', e.args[0], e.args[1]) - c.close() - cls.invalidate_connection() - if retry: - # Make sure we only retry once - info('Retrying database operation') - kwargs["threadDB__retry_execution__"] = True - c = cls.execute(*args, **kwargs) - else: - error('Database operation failed ultimately') - 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) +# === Main Application === def do_main_program(): # #--- Authenticator implementation @@ -222,101 +118,60 @@ def do_main_program(): if cfg.ice.watchdog > 0: self.failedWatch = True self.checkConnection() - - # Serve till we are stopped self.communicator().waitForShutdown() - self.watchdog.cancel() - + + if hasattr(self, 'watchdog'): + self.watchdog.cancel() if self.interrupted(): - warning('Caught interrupt, shutting down') - - threadDB.disconnect() + warning('Interrupt received - shutting down') return 0 - + def initializeIceConnection(self): - """ - Establishes the two-way Ice connection and adds the authenticator to the - configured servers - """ ice = self.communicator() - if cfg.ice.secret: - debug('Using shared ice secret') ice.getImplicitContext().put("secret", cfg.ice.secret) elif not cfg.glacier.enabled: - warning('Consider using an ice secret to improve security') + warning('No Ice secret configured - security risk') - 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)) - self.meta = MumbleServer.MetaPrx.uncheckedCast(base) - - adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', 'tcp -h %s' % cfg.ice.host) + info('Connecting to Ice at %s:%d', cfg.ice.host, cfg.ice.port) + proxy = ice.stringToProxy(f'Meta:tcp -h {cfg.ice.host} -p {cfg.ice.port}') + self.meta = MumbleServer.MetaPrx.uncheckedCast(proxy) + + adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', f'tcp -h {cfg.ice.host}') adapter.activate() - metacbprx = adapter.addWithUUID(metaCallback(self)) - self.metacb = MumbleServer.MetaCallbackPrx.uncheckedCast(metacbprx) - - authprx = adapter.addWithUUID(CVoipAuthenticator()) - self.auth = MumbleServer.ServerUpdatingAuthenticatorPrx.uncheckedCast(authprx) - + self.metacb = MumbleServer.MetaCallbackPrx.uncheckedCast( + adapter.addWithUUID(metaCallback(self)) + ) + self.auth = MumbleServer.ServerUpdatingAuthenticatorPrx.uncheckedCast( + adapter.addWithUUID(CVoipAuthenticator()) + ) return self.attachCallbacks() - - def attachCallbacks(self, quiet = False): - """ - Attaches all callbacks for meta and authenticators - """ - - # Ice.ConnectionRefusedException - #debug('Attaching callbacks') - try: - if not quiet: info('Attaching meta callback') + def attachCallbacks(self, quiet=False): + try: + if not quiet: + info('Attaching meta callback') self.meta.addCallback(self.metacb) - for server in self.meta.getBootedServers(): if not cfg.murmur.servers or server.id() in cfg.murmur.servers: - if not quiet: info('Setting authenticator for virtual server %d', server.id()) + if not quiet: + info('Configuring authenticator for server %d', server.id()) server.setAuthenticator(self.auth) - - except (MumbleServer.InvalidSecretException, Ice.UnknownUserException, Ice.ConnectionRefusedException) as e: - if isinstance(e, Ice.ConnectionRefusedException): - error('Server refused connection') - elif isinstance(e, MumbleServer.InvalidSecretException) or \ - isinstance(e, Ice.UnknownUserException) and (e.unknown == 'MumbleServer::InvalidSecretException'): - error('Invalid ice secret') - else: - # We do not actually want to handle this one, re-raise it - raise e - + self.connected = True + return True + except (MumbleServer.InvalidSecretException, Ice.UnknownUserException) as e: + error('Connection failed: %s', str(e)) self.connected = False return False - self.connected = True - return True - def checkConnection(self): - """ - Tries reapplies all callbacks to make sure the authenticator - survives server restarts and disconnects. - """ - #debug('Watchdog run') - try: - if not self.attachCallbacks(quiet = not self.failedWatch): - self.failedWatch = True - else: - self.failedWatch = False + self.attachCallbacks(quiet=not self.failedWatch) + self.failedWatch = False except Ice.Exception as e: - error('Failed connection check, will retry in next watchdog run (%ds)', cfg.ice.watchdog) - debug(str(e)) + error('Connection check failed: %s', str(e)) self.failedWatch = True - - # Renew the timer self.watchdog = Timer(cfg.ice.watchdog, self.checkConnection) self.watchdog.start() @@ -431,12 +286,6 @@ def do_main_program(): @fortifyIceFu(authenticateFortifyResult) @checkSecret def authenticate(self, name, pw, certlist, certhash, strong, current = None): - """ - This function is called to authenticate a user - """ - debug(f'The user\'s password: {pw}') - - # Search for the user in the database FALL_THROUGH = -2 AUTH_REFUSED = -1 @@ -458,7 +307,7 @@ def do_main_program(): try: user = User.objects.get(username=name) debug('User found: %s', user.username) - if user.check_password(pw): + if user.check_password(pw) and pw not in ('', None): # Successful authentication uid = user.id + cfg.user.id_offset groups = [group.name for group in user.groups.all()] @@ -470,48 +319,8 @@ def do_main_program(): except User.DoesNotExist: info('Refuse Connection for unknown user "%s"', name) return (AUTH_REFUSED, None, None) - - if name == 'SuperUser': - debug('Forced fall through for SuperUser') - return (FALL_THROUGH, None, None) - - try: - sql = 'SELECT id_member, passwd, id_group, member_name, real_name, additional_groups, is_activated FROM %smembers WHERE LOWER(member_name) = 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, ugroupid, uname, urealname, uadditgroups, activated = res - - if activated == 1 and smf_check_hash(pw, upw, uname): - # Authenticated, fetch group memberships - try: - if uadditgroups: - groupids = str(ugroupid) + ',' + uadditgroups - else: - groupids = str(ugroupid) - sql = 'SELECT group_name FROM %smembergroups WHERE id_group IN (%s)' % (cfg.database.prefix, groupids) - cur = threadDB.execute(sql) - except threadDbException: - return (FALL_THROUGH, None, None) - - groups = cur.fetchall() - cur.close() - if groups: - groups = [a[0] for a in groups] - - info('User authenticated: "%s" (%d)', name, uid + cfg.user.id_offset) - debug('Group memberships: %s', str(groups)) - return (uid + cfg.user.id_offset, entity_decode(urealname), groups) - - info('Failed authentication attempt for user: "%s" (%d)', name, uid + cfg.user.id_offset) + info('Failed authentication attempt for user: "%s"', name) return (AUTH_REFUSED, None, None) @fortifyIceFu((False, None)) @@ -536,21 +345,6 @@ def do_main_program(): if name == 'SuperUser': debug('nameToId SuperUser -> forced fall through') return FALL_THROUGH - - try: - sql = 'SELECT id_member FROM %smembers WHERE LOWER(member_name) = 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 @fortifyIceFu("") @checkSecret @@ -565,23 +359,6 @@ def do_main_program(): return FALL_THROUGH bbid = id - cfg.user.id_offset - # Fetch the user from the database - try: - sql = 'SELECT member_name 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 @@ -599,66 +376,6 @@ def do_main_program(): 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 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 - avatar = res[0] - - if not avatar: - # Either the user has none or it is in the attachments, check there - try: - sql = '''SELECT id_attach, file_hash, filename, attachment_type FROM %sattachments WHERE approved = true AND - (attachment_type = 0 OR attachment_type = 1) AND 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 - - fid, fhash, filename, fattachtype = res - if cfg.forum.path.startswith('file://'): - # We are supposed to load this from the local fs - avatar_file = cfg.forum.path + 'attachments/%d_%s' % (fid, fhash) - elif fattachtype == 0: - avatar_file = cfg.forum.path + 'index.php?action=dlattach;attach=%d;type=avatar' % fid - elif fattachtype == 1: - avatar_file = cfg.forum.path + 'avatars/' + filename - elif "://" in avatar: - # ...or it is a external link - avatar_file = avatar - else: - warning("avatar with an unexpected value, fall through") - return FALL_THROUGH - - if avatar_file in self.texture_cache: - return self.texture_cache[avatar_file] - - try: - handle = urllib.request.urlopen(avatar_file) - filecontent = handle.read() - handle.close() - except urllib.error.URLError as e: - warning('Image download for "%s" (%d) failed: %s', avatar_file, id, str(e)) - return FALL_THROUGH - - self.texture_cache[avatar_file] = filecontent - - return self.texture_cache[avatar_file] - @fortifyIceFu(-2) @checkSecret def registerUser(self, name, current = None): @@ -682,31 +399,6 @@ def do_main_program(): # but we can make murmur delete all additional information it got this way. debug('unregisterUser %d -> fall through', id) return FALL_THROUGH - - @fortifyIceFu({}) - @checkSecret - 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, member_name FROM %smembers WHERE is_activated = 1 AND member_name 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]) @fortifyIceFu(-1) @checkSecret @@ -767,9 +459,7 @@ def do_main_program(): def error(self, message): self._log.error(message) - # - #--- Start of authenticator - # + # === Start of authenticator === info('Starting cvoip authenticator') initdata = Ice.InitializationData() initdata.properties = Ice.createProperties([], initdata.properties) @@ -784,30 +474,7 @@ def do_main_program(): 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 - """ - ret = False - - try: - # SMF 2.1 uses a bcrypt hash, try that first - ret = bcrypt.hashpw((username.lower() + password).encode('utf-8'), hash.encode('utf-8')) == hash - except ValueError: - # The sha1 password hash from SMF 2.0 and earlier will cause a salt value error - # In that case, try the legacy sha1 hash - ret = sha1((username.lower() + password).encode('utf-8')).hexdigest() == hash - - return ret - -# -#--- Start of program -# +# === Entry Point === if __name__ == '__main__': # Parse commandline options parser = OptionParser() @@ -835,17 +502,9 @@ if __name__ == '__main__': except Exception as e: print('Fatal error, could not load config file from "%s"' % cfgfile, file=sys.stderr) sys.exit(1) - - try: - db = __import__(cfg.database.lib) - except ImportError as e: - print('Fatal error, could not import database library "%s", '\ - 'please install the missing dependency and restart the authenticator' % cfg.database.lib, file=sys.stderr) - sys.exit(1) - # Initialize logger - if cfg.log.file: + if cfg.log.file and option.logfile: try: logfile = open(cfg.log.file, 'a') except IOError as e: @@ -885,8 +544,4 @@ if __name__ == '__main__': try: do_main_program() finally: - context.__exit__(None, None, None) - - -# Change django dB to postgres - Done -# An extra option to display foreground logs - Done \ No newline at end of file + context.__exit__(None, None, None) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d0753cc..ae014b9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,15 @@ annotated-types==0.7.0 anyio==4.9.0 asgiref==3.8.1 -bcrypt==4.3.0 Django==5.2.2 idna==3.10 lockfile==0.12.2 -mysqlclient==2.2.7 psycopg==3.2.9 psycopg-binary==3.2.9 pydantic==2.11.5 pydantic_core==2.33.2 python-daemon==3.1.2 sniffio==1.3.1 -sqlparse==0.5.3 starlette==0.46.2 typing-inspection==0.4.1 typing_extensions==4.14.0