refact: smf-cleanup

This commit is contained in:
XDreamist 2025-06-22 18:16:01 +05:30
parent 46e7837346
commit c9c54ac81a
3 changed files with 56 additions and 415 deletions

View File

@ -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 configuration
[django] [django]
;If true, the authenticator will use Django to handle user authentication instead of the database ;If true, the authenticator will use Django to handle user authentication instead of the database

View File

@ -1,41 +1,23 @@
import sys import sys
import Ice import Ice
import _thread
import urllib.request, urllib.error, urllib.parse
import logging import logging
import configparser import configparser
import bcrypt from threading import Timer
from optparse import OptionParser
from threading import Timer from logging import debug, info, warning, error, critical, exception, getLogger
from optparse import OptionParser
from logging import (debug,
info,
warning,
error,
critical,
exception,
getLogger)
from hashlib import sha1
# === Configuration Helpers ===
def x2bool(s): def x2bool(s):
"""Helper function to convert strings from the config to bool""" """Convert config strings to boolean"""
if isinstance(s, bool): if isinstance(s, bool):
return s return s
elif isinstance(s, str): elif isinstance(s, str):
return s.lower() in ['1', 'true'] return s.lower() in ['1', 'true']
raise ValueError() raise ValueError()
# # === Default Configuration ===
#--- Default configuration values
#
cfgfile = 'CVoipAuth.ini' cfgfile = 'CVoipAuth.ini'
default = {'database':(('lib', str, 'MySQLdb'), default = {'django':(('enabled', x2bool, False),
('password', str, 'secret'),
('host', str, '127.0.0.1'),
('port', int, 3306)),
'django':(('enabled', x2bool, False),
('project', str, 'CVoipPanel'), ('project', str, 'CVoipPanel'),
('settings', str, 'CVoipPanel.settings')), ('settings', str, 'CVoipPanel.settings')),
@ -61,35 +43,29 @@ default = {'database':(('lib', str, 'MySQLdb'),
'log':(('level', int, logging.DEBUG), 'log':(('level', int, logging.DEBUG),
('file', str, 'CVoipAuth.log'))} ('file', str, 'CVoipAuth.log'))}
# # === Helper classes ===
#--- Helper classes
#
class config(object): class config(object):
"""
Small abstraction for config loading
"""
def __init__(self, filename = None, default = None): def __init__(self, filename = None, default = None):
if not filename or not default: return if not filename or not default: return
cfg = configparser.ConfigParser() cfg = configparser.ConfigParser()
cfg.optionxform = str cfg.optionxform = str
cfg.read(filename) cfg.read(filename)
for section, values in default.items():
for h,v in default.items(): if not values:
if not v:
# Output this whole section as a list of raw key/value tuples
try: try:
self.__dict__[h] = cfg.items(h) self.__dict__[section] = cfg.items(section)
except configparser.NoSectionError: except configparser.NoSectionError:
self.__dict__[h] = [] self.__dict__[section] = []
else: else:
self.__dict__[h] = config() self.__dict__[section] = config()
for name, conv, vdefault in v: for name, conv, vdefault in values:
try: 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): 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): def entity_decode(string):
""" """
Python reverse implementation of php htmlspecialchars Python reverse implementation of php htmlspecialchars
@ -117,88 +93,8 @@ def entity_encode(string):
for (s,t) in htmlspecialchars: for (s,t) in htmlspecialchars:
ret = ret.replace(s, t) ret = ret.replace(s, t)
return ret 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(): def do_main_program():
# #
#--- Authenticator implementation #--- Authenticator implementation
@ -222,101 +118,60 @@ def do_main_program():
if cfg.ice.watchdog > 0: if cfg.ice.watchdog > 0:
self.failedWatch = True self.failedWatch = True
self.checkConnection() self.checkConnection()
# Serve till we are stopped
self.communicator().waitForShutdown() self.communicator().waitForShutdown()
self.watchdog.cancel()
if hasattr(self, 'watchdog'):
self.watchdog.cancel()
if self.interrupted(): if self.interrupted():
warning('Caught interrupt, shutting down') warning('Interrupt received - shutting down')
threadDB.disconnect()
return 0 return 0
def initializeIceConnection(self): def initializeIceConnection(self):
"""
Establishes the two-way Ice connection and adds the authenticator to the
configured servers
"""
ice = self.communicator() ice = self.communicator()
if cfg.ice.secret: if cfg.ice.secret:
debug('Using shared ice secret')
ice.getImplicitContext().put("secret", cfg.ice.secret) ice.getImplicitContext().put("secret", cfg.ice.secret)
elif not cfg.glacier.enabled: 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 Ice at %s:%d', cfg.ice.host, cfg.ice.port)
#info('Connecting to Glacier2 server (%s:%d)', glacier_host, glacier_port) proxy = ice.stringToProxy(f'Meta:tcp -h {cfg.ice.host} -p {cfg.ice.port}')
error('Glacier support not implemented yet') self.meta = MumbleServer.MetaPrx.uncheckedCast(proxy)
#TODO: Implement this
adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', f'tcp -h {cfg.ice.host}')
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)
adapter.activate() adapter.activate()
metacbprx = adapter.addWithUUID(metaCallback(self)) self.metacb = MumbleServer.MetaCallbackPrx.uncheckedCast(
self.metacb = MumbleServer.MetaCallbackPrx.uncheckedCast(metacbprx) adapter.addWithUUID(metaCallback(self))
)
authprx = adapter.addWithUUID(CVoipAuthenticator()) self.auth = MumbleServer.ServerUpdatingAuthenticatorPrx.uncheckedCast(
self.auth = MumbleServer.ServerUpdatingAuthenticatorPrx.uncheckedCast(authprx) adapter.addWithUUID(CVoipAuthenticator())
)
return self.attachCallbacks() 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) self.meta.addCallback(self.metacb)
for server in self.meta.getBootedServers(): for server in self.meta.getBootedServers():
if not cfg.murmur.servers or server.id() in cfg.murmur.servers: 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) server.setAuthenticator(self.auth)
self.connected = True
except (MumbleServer.InvalidSecretException, Ice.UnknownUserException, Ice.ConnectionRefusedException) as e: return True
if isinstance(e, Ice.ConnectionRefusedException): except (MumbleServer.InvalidSecretException, Ice.UnknownUserException) as e:
error('Server refused connection') error('Connection failed: %s', str(e))
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 = False self.connected = False
return False return False
self.connected = True
return True
def checkConnection(self): def checkConnection(self):
"""
Tries reapplies all callbacks to make sure the authenticator
survives server restarts and disconnects.
"""
#debug('Watchdog run')
try: try:
if not self.attachCallbacks(quiet = not self.failedWatch): self.attachCallbacks(quiet=not self.failedWatch)
self.failedWatch = True self.failedWatch = False
else:
self.failedWatch = False
except Ice.Exception as e: except Ice.Exception as e:
error('Failed connection check, will retry in next watchdog run (%ds)', cfg.ice.watchdog) error('Connection check failed: %s', str(e))
debug(str(e))
self.failedWatch = True self.failedWatch = True
# Renew the timer
self.watchdog = Timer(cfg.ice.watchdog, self.checkConnection) self.watchdog = Timer(cfg.ice.watchdog, self.checkConnection)
self.watchdog.start() self.watchdog.start()
@ -431,12 +286,6 @@ def do_main_program():
@fortifyIceFu(authenticateFortifyResult) @fortifyIceFu(authenticateFortifyResult)
@checkSecret @checkSecret
def authenticate(self, name, pw, certlist, certhash, strong, current = None): 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 FALL_THROUGH = -2
AUTH_REFUSED = -1 AUTH_REFUSED = -1
@ -458,7 +307,7 @@ def do_main_program():
try: try:
user = User.objects.get(username=name) user = User.objects.get(username=name)
debug('User found: %s', user.username) debug('User found: %s', user.username)
if user.check_password(pw): if user.check_password(pw) and pw not in ('', None):
# Successful authentication # Successful authentication
uid = user.id + cfg.user.id_offset uid = user.id + cfg.user.id_offset
groups = [group.name for group in user.groups.all()] groups = [group.name for group in user.groups.all()]
@ -470,48 +319,8 @@ def do_main_program():
except User.DoesNotExist: except User.DoesNotExist:
info('Refuse Connection for unknown user "%s"', name) info('Refuse Connection for unknown user "%s"', name)
return (AUTH_REFUSED, None, None) 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) info('Failed authentication attempt for user: "%s"', name)
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)
return (AUTH_REFUSED, None, None) return (AUTH_REFUSED, None, None)
@fortifyIceFu((False, None)) @fortifyIceFu((False, None))
@ -536,21 +345,6 @@ def do_main_program():
if name == 'SuperUser': if name == 'SuperUser':
debug('nameToId SuperUser -> forced fall through') debug('nameToId SuperUser -> forced fall through')
return 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("") @fortifyIceFu("")
@checkSecret @checkSecret
@ -565,23 +359,6 @@ def do_main_program():
return FALL_THROUGH return FALL_THROUGH
bbid = id - cfg.user.id_offset 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) debug('idToName %d -> ?', id)
return FALL_THROUGH return FALL_THROUGH
@ -599,66 +376,6 @@ def do_main_program():
debug('idToTexture %d -> fall through', id) debug('idToTexture %d -> fall through', id)
return FALL_THROUGH 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) @fortifyIceFu(-2)
@checkSecret @checkSecret
def registerUser(self, name, current = None): 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. # but we can make murmur delete all additional information it got this way.
debug('unregisterUser %d -> fall through', id) debug('unregisterUser %d -> fall through', id)
return FALL_THROUGH 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) @fortifyIceFu(-1)
@checkSecret @checkSecret
@ -767,9 +459,7 @@ def do_main_program():
def error(self, message): def error(self, message):
self._log.error(message) self._log.error(message)
# # === Start of authenticator ===
#--- Start of authenticator
#
info('Starting cvoip authenticator') info('Starting cvoip authenticator')
initdata = Ice.InitializationData() initdata = Ice.InitializationData()
initdata.properties = Ice.createProperties([], initdata.properties) initdata.properties = Ice.createProperties([], initdata.properties)
@ -784,30 +474,7 @@ def do_main_program():
state = app.main(sys.argv[:1], initData = initdata) state = app.main(sys.argv[:1], initData = initdata)
info('Shutdown complete') info('Shutdown complete')
# === Entry Point ===
#
#--- 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
#
if __name__ == '__main__': if __name__ == '__main__':
# Parse commandline options # Parse commandline options
parser = OptionParser() parser = OptionParser()
@ -835,17 +502,9 @@ if __name__ == '__main__':
except Exception as e: except Exception as e:
print('Fatal error, could not load config file from "%s"' % cfgfile, file=sys.stderr) print('Fatal error, could not load config file from "%s"' % cfgfile, file=sys.stderr)
sys.exit(1) 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 # Initialize logger
if cfg.log.file: if cfg.log.file and option.logfile:
try: try:
logfile = open(cfg.log.file, 'a') logfile = open(cfg.log.file, 'a')
except IOError as e: except IOError as e:
@ -885,8 +544,4 @@ if __name__ == '__main__':
try: try:
do_main_program() do_main_program()
finally: finally:
context.__exit__(None, None, None) context.__exit__(None, None, None)
# Change django dB to postgres - Done
# An extra option to display foreground logs - Done

View File

@ -1,18 +1,15 @@
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.9.0 anyio==4.9.0
asgiref==3.8.1 asgiref==3.8.1
bcrypt==4.3.0
Django==5.2.2 Django==5.2.2
idna==3.10 idna==3.10
lockfile==0.12.2 lockfile==0.12.2
mysqlclient==2.2.7
psycopg==3.2.9 psycopg==3.2.9
psycopg-binary==3.2.9 psycopg-binary==3.2.9
pydantic==2.11.5 pydantic==2.11.5
pydantic_core==2.33.2 pydantic_core==2.33.2
python-daemon==3.1.2 python-daemon==3.1.2
sniffio==1.3.1 sniffio==1.3.1
sqlparse==0.5.3
starlette==0.46.2 starlette==0.46.2
typing-inspection==0.4.1 typing-inspection==0.4.1
typing_extensions==4.14.0 typing_extensions==4.14.0