Handle websocket autoconnect and remove session duplicates. Resolves #1851. Resolves #1562.

This commit is contained in:
Griatch 2019-06-15 22:24:32 +02:00
parent 993113b2b7
commit 005b3f4530
13 changed files with 114 additions and 64 deletions

View file

@ -2,10 +2,12 @@
## Evennia 0.9 (2018-2019) ## Evennia 0.9 (2018-2019)
Update to Python 3 ### Distribution
- Use `python3 -m venv <myenvname>` - New requirement: Python 3.7 (py2.7 support removed)
- Use `python3 -m pdb <script>` for debugging - Django 2.1
- Twisted 19.2.1
- Autobahn websockets (remove old tmwx)
- Docker image updated - Docker image updated
### Commands ### Commands
@ -20,6 +22,7 @@ Update to Python 3
that are calculated on the fly. that are calculated on the fly.
- `@py` command now defaults to escaping html tags in its output when viewing in the webclient. - `@py` command now defaults to escaping html tags in its output when viewing in the webclient.
Use new `/clientraw` switch to get old behavior (issue #1369). Use new `/clientraw` switch to get old behavior (issue #1369).
- Shorter and more informative, dynamic, listing of on-command vars if not setting func() in child command class.
### Web ### Web
@ -73,7 +76,7 @@ Update to Python 3
- Prettifies Django 'change password' workflow - Prettifies Django 'change password' workflow
- Bugfixes - Bugfixes
- Fixes bug on login page where error messages were not being displayed - Fixes bug on login page where error messages were not being displayed
- Remove strvalue field from admin; it made no sense to have here, being an optimization field - Remove strvalue field from admin; it made no sense to have here, being an optimization field
for internal use. for internal use.
### Prototypes ### Prototypes
@ -115,6 +118,8 @@ Update to Python 3
- Added `evennia.ANSIString` to flat API. - Added `evennia.ANSIString` to flat API.
- Server/Portal log files now cycle to names on the form `server_.log_19_03_08_` instead of `server.log___19.3.8`, retaining - Server/Portal log files now cycle to names on the form `server_.log_19_03_08_` instead of `server.log___19.3.8`, retaining
unix file sorting order. unix file sorting order.
- Django signals fire for important events: Puppet/Unpuppet, Object create/rename, Login,
Logout, Login fail Disconnect, Account create/rename
### Utils ### Utils

View file

@ -420,7 +420,9 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
return False return False
@classmethod @classmethod
def get_username_validators(cls, validator_config=getattr(settings, 'AUTH_USERNAME_VALIDATORS', [])): def get_username_validators(
cls, validator_config=getattr(
settings, 'AUTH_USERNAME_VALIDATORS', [])):
""" """
Retrieves and instantiates validators for usernames. Retrieves and instantiates validators for usernames.
@ -437,7 +439,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
try: try:
klass = import_string(validator['NAME']) klass = import_string(validator['NAME'])
except ImportError: except ImportError:
msg = "The module in NAME could not be imported: %s. Check your AUTH_USERNAME_VALIDATORS setting." msg = ("The module in NAME could not be imported: %s. "
"Check your AUTH_USERNAME_VALIDATORS setting.")
raise ImproperlyConfigured(msg % validator['NAME']) raise ImproperlyConfigured(msg % validator['NAME'])
objs.append(klass(**validator.get('OPTIONS', {}))) objs.append(klass(**validator.get('OPTIONS', {})))
return objs return objs
@ -473,7 +476,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
""" """
errors = [] errors = []
if ip: ip = str(ip) if ip:
ip = str(ip)
# See if authentication is currently being throttled # See if authentication is currently being throttled
if ip and LOGIN_THROTTLE.check(ip): if ip and LOGIN_THROTTLE.check(ip):
@ -488,8 +492,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
banned = cls.is_banned(username=username, ip=ip) banned = cls.is_banned(username=username, ip=ip)
if banned: if banned:
# this is a banned IP or name! # this is a banned IP or name!
errors.append("|rYou have been banned and cannot continue from here." \ errors.append("|rYou have been banned and cannot continue from here."
"\nIf you feel this ban is in error, please email an admin.|x") "\nIf you feel this ban is in error, please email an admin.|x")
logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip)) logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip))
LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.') LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.')
return None, errors return None, errors
@ -504,7 +508,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip))
# Update throttle # Update throttle
if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures.') if ip:
LOGIN_THROTTLE.update(ip, 'Too many authentication failures.')
# Try to call post-failure hook # Try to call post-failure hook
session = kwargs.get('session', None) session = kwargs.get('session', None)
@ -573,7 +578,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
# Disqualify if any check failed # Disqualify if any check failed
if False in valid: if False in valid:
valid = False valid = False
else: valid = True else:
valid = True
return valid, errors return valid, errors
@ -713,7 +719,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
account.db.FIRST_LOGIN = True account.db.FIRST_LOGIN = True
# Record IP address of creation, if available # Record IP address of creation, if available
if ip: account.db.creator_ip = ip if ip:
account.db.creator_ip = ip
# join the new account to the public channel # join the new account to the public channel
pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"]) pchannel = ChannelDB.objects.get_channel(settings.DEFAULT_CHANNELS[0]["key"])
@ -933,7 +940,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
""" """
result = super().access(accessing_obj, access_type=access_type, result = super().access(accessing_obj, access_type=access_type,
default=default, no_superuser_bypass=no_superuser_bypass) default=default, no_superuser_bypass=no_superuser_bypass)
self.at_access(result, accessing_obj, access_type, **kwargs) self.at_access(result, accessing_obj, access_type, **kwargs)
return result return result
@ -1447,7 +1454,8 @@ class DefaultGuest(DefaultAccount):
break break
if not username: if not username:
errors.append("All guest accounts are in use. Please try again later.") errors.append("All guest accounts are in use. Please try again later.")
if ip: LOGIN_THROTTLE.update(ip, 'Too many requests for Guest access.') if ip:
LOGIN_THROTTLE.update(ip, 'Too many requests for Guest access.')
return None, errors return None, errors
else: else:
# build a new account with the found guest username # build a new account with the found guest username

View file

@ -414,6 +414,14 @@ class Command(with_metaclass(CommandMeta, object)):
set in self.parse()) set in self.parse())
""" """
variables = '\n'.join(" |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items())
string = f"""
Command {self} has no defined `func()` - showing on-command variables:
{variables}
"""
self.caller.msg(string)
return
# a simple test command to show the available properties # a simple test command to show the available properties
string = "-" * 50 string = "-" * 50
string += "\n|w%s|n - Command variables from evennia:\n" % self.key string += "\n|w%s|n - Command variables from evennia:\n" % self.key

View file

@ -200,6 +200,13 @@ class MuxCommand(Command):
by the cmdhandler right after self.parser() finishes, and so has access by the cmdhandler right after self.parser() finishes, and so has access
to all the variables defined therein. to all the variables defined therein.
""" """
variables = '\n'.join(" |w{}|n ({}): {}".format(key, type(val), val) for key, val in self.__dict__.items())
string = f"""
Command {self} has no defined `func()` - showing on-command variables: No child func() defined for {self} - available variables:
{variables}
"""
self.caller.msg(string)
return
# a simple test command to show the available properties # a simple test command to show the available properties
string = "-" * 50 string = "-" * 50
string += "\n|w%s|n - Command variables from evennia:\n" % self.key string += "\n|w%s|n - Command variables from evennia:\n" % self.key

View file

@ -446,7 +446,7 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
nobjs = nobjs or 1 # fix zero-div error with empty database nobjs = nobjs or 1 # fix zero-div error with empty database
# total object sum table # total object sum table
totaltable = self.style_table("|wtype|n", "|wcomment|n", "|wcount|n", "|w%%|n", totaltable = self.style_table("|wtype|n", "|wcomment|n", "|wcount|n", "|w%|n",
border="table", align="l") border="table", align="l")
totaltable.align = 'l' totaltable.align = 'l'
totaltable.add_row("Characters", "(BASE_CHARACTER_TYPECLASS + children)", totaltable.add_row("Characters", "(BASE_CHARACTER_TYPECLASS + children)",
@ -458,7 +458,8 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
totaltable.add_row("Other", "", nother, "%.2f" % ((float(nother) / nobjs) * 100)) totaltable.add_row("Other", "", nother, "%.2f" % ((float(nother) / nobjs) * 100))
# typeclass table # typeclass table
typetable = self.style_table("|wtypeclass|n", "|wcount|n", "|w%%|n", border="table", align="l") typetable = self.style_table("|wtypeclass|n", "|wcount|n", "|w%|n",
border="table", align="l")
typetable.align = 'l' typetable.align = 'l'
dbtotals = ObjectDB.objects.object_totals() dbtotals = ObjectDB.objects.object_totals()
for path, count in dbtotals.items(): for path, count in dbtotals.items():
@ -466,7 +467,8 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
# last N table # last N table
objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim):] objs = ObjectDB.objects.all().order_by("db_date_created")[max(0, nobjs - nlim):]
latesttable = self.style_table("|wcreated|n", "|wdbref|n", "|wname|n", "|wtypeclass|n", align="l", border="table") latesttable = self.style_table("|wcreated|n", "|wdbref|n", "|wname|n",
"|wtypeclass|n", align="l", border="table")
latesttable.align = 'l' latesttable.align = 'l'
for obj in objs: for obj in objs:
latesttable.add_row(utils.datetime_format(obj.date_created), latesttable.add_row(utils.datetime_format(obj.date_created),

View file

@ -21,7 +21,7 @@ class Command(BaseCommand):
Each Command implements the following methods, called Each Command implements the following methods, called
in this order (only func() is actually required): in this order (only func() is actually required):
- at_pre_cmd(): If this returns True, execution is aborted. - at_pre_cmd(): If this returns anything truthy, execution is aborted.
- parse(): Should perform any extra parsing needed on self.args - parse(): Should perform any extra parsing needed on self.args
and store the result on self. and store the result on self.
- func(): Performs the actual work. - func(): Performs the actual work.

View file

@ -90,8 +90,11 @@ class PortalSessionHandler(SessionHandler):
if session: if session:
# assign if we are first-connectors # assign if we are first-connectors
self.latest_sessid += 1 if not session.sessid:
session.sessid = self.latest_sessid # if the session already has a sessid (e.g. being inherited in the
# case of a webclient auto-reconnect), keep it
self.latest_sessid += 1
session.sessid = self.latest_sessid
session.server_connected = False session.server_connected = False
_CONNECTION_QUEUE.appendleft(session) _CONNECTION_QUEUE.appendleft(session)
if len(_CONNECTION_QUEUE) > 1: if len(_CONNECTION_QUEUE) > 1:

View file

@ -29,6 +29,9 @@ _RE_SCREENREADER_REGEX = re.compile(r"%s" % settings.SCREENREADER_REGEX_STRIP, r
_CLIENT_SESSIONS = mod_import(settings.SESSION_ENGINE).SessionStore _CLIENT_SESSIONS = mod_import(settings.SESSION_ENGINE).SessionStore
CLOSE_NORMAL = WebSocketServerProtocol.CLOSE_STATUS_CODE_NORMAL
class WebSocketClient(WebSocketServerProtocol, Session): class WebSocketClient(WebSocketServerProtocol, Session):
""" """
Implements the server-side of the Websocket connection. Implements the server-side of the Websocket connection.
@ -70,19 +73,21 @@ class WebSocketClient(WebSocketServerProtocol, Session):
client_address = client_address[0] if client_address else None client_address = client_address[0] if client_address else None
self.init_session("websocket", client_address, self.factory.sessionhandler) self.init_session("websocket", client_address, self.factory.sessionhandler)
from evennia.utils import logger csession = self.get_client_session() # this sets self.csessid
try: csessid = self.csessid
csessid = self.http_request_uri.split("?", 1)[1]
except Exception:
logger.log_trace(str(self.__dict__))
csession = self.get_client_session()
uid = csession and csession.get("webclient_authenticated_uid", None) uid = csession and csession.get("webclient_authenticated_uid", None)
if uid: if uid:
# the client session is already logged in. # the client session is already logged in.
self.uid = uid self.uid = uid
self.logged_in = True self.logged_in = True
for old_session in self.sessionhandler.sessions_from_csessid(csessid):
if (hasattr(old_session, "websocket_close_code") and
old_session.websocket_close_code != CLOSE_NORMAL):
# if we have old sessions with the same csession, they are remnants
self.sessid = old_session.sessid
self.sessionhandler.disconnect(old_session)
# watch for dead links # watch for dead links
self.transport.setTcpKeepAlive(1) self.transport.setTcpKeepAlive(1)
# actually do the connection # actually do the connection
@ -97,11 +102,19 @@ class WebSocketClient(WebSocketServerProtocol, Session):
reason (str or None): Motivation for the disconnection. reason (str or None): Motivation for the disconnection.
""" """
csession = self.get_client_session()
if csession:
csession["webclient_authenticated_uid"] = None
csession.save()
self.logged_in = False
self.sessionhandler.disconnect(self)
# autobahn-python: 1000 for a normal close, 3000-4999 for app. specific, # autobahn-python: 1000 for a normal close, 3000-4999 for app. specific,
# in case anyone wants to expose this functionality later. # in case anyone wants to expose this functionality later.
# #
# sendClose() under autobahn/websocket/interfaces.py # sendClose() under autobahn/websocket/interfaces.py
self.sendClose(1000, reason) self.sendClose(CLOSE_NORMAL, reason)
def onClose(self, wasClean, code=None, reason=None): def onClose(self, wasClean, code=None, reason=None):
""" """
@ -111,19 +124,14 @@ class WebSocketClient(WebSocketServerProtocol, Session):
Args: Args:
wasClean (bool): ``True`` if the WebSocket was closed cleanly. wasClean (bool): ``True`` if the WebSocket was closed cleanly.
reason (str): Motivation for the lost connection.
code (int or None): Close status as sent by the WebSocket peer. code (int or None): Close status as sent by the WebSocket peer.
reason (str or None): Close reason as sent by the WebSocket peer. reason (str or None): Close reason as sent by the WebSocket peer.
""" """
csession = self.get_client_session() if code == CLOSE_NORMAL:
self.disconnect(reason)
if csession: else:
csession["webclient_authenticated_uid"] = None self.websocket_close_code = code
csession.save()
self.logged_in = False
self.sessionhandler.disconnect(self)
def onMessage(self, payload, isBinary): def onMessage(self, payload, isBinary):
""" """

View file

@ -214,7 +214,7 @@ class SessionHandler(dict):
return newdict return newdict
elif is_iter(data): elif is_iter(data):
return [_validate(part) for part in data] return [_validate(part) for part in data]
elif isinstance(data, (str, bytes )): elif isinstance(data, (str, bytes)):
data = _utf8(data) data = _utf8(data)
if _INLINEFUNC_ENABLED and not raw and isinstance(self, ServerSessionHandler): if _INLINEFUNC_ENABLED and not raw and isinstance(self, ServerSessionHandler):
@ -257,9 +257,9 @@ class SessionHandler(dict):
return rkwargs return rkwargs
#------------------------------------------------------------ # ------------------------------------------------------------
# Server-SessionHandler class # Server-SessionHandler class
#------------------------------------------------------------ # ------------------------------------------------------------
class ServerSessionHandler(SessionHandler): class ServerSessionHandler(SessionHandler):
""" """
@ -413,7 +413,7 @@ class ServerSessionHandler(SessionHandler):
# set a watchdog to avoid self.disconnect from deleting # set a watchdog to avoid self.disconnect from deleting
# the session while we are looping over them # the session while we are looping over them
self._disconnect_all = True self._disconnect_all = True
for session in self.values: for session in self.values():
session.disconnect() session.disconnect()
del self._disconnect_all del self._disconnect_all
@ -443,7 +443,8 @@ class ServerSessionHandler(SessionHandler):
""" """
self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN, self.server.amp_protocol.send_AdminServer2Portal(DUMMYSESSION, operation=SCONN,
protocol_path=protocol_path, config=configdict) protocol_path=protocol_path,
config=configdict)
def portal_restart_server(self): def portal_restart_server(self):
""" """
@ -544,7 +545,8 @@ class ServerSessionHandler(SessionHandler):
nsess = len(self.sessions_from_account(session.account)) - 1 nsess = len(self.sessions_from_account(session.account)) - 1
sreason = " ({})".format(reason) if reason else "" sreason = " ({})".format(reason) if reason else ""
string = "Logged out: {account} {address} ({nsessions} sessions(s) remaining){reason}" string = "Logged out: {account} {address} ({nsessions} sessions(s) remaining){reason}"
string = string.format(reason=sreason, account=session.account, address=session.address, nsessions=nsess) string = string.format(reason=sreason, account=session.account,
address=session.address, nsessions=nsess)
session.log(string) session.log(string)
if nsess == 0: if nsess == 0:
@ -670,7 +672,8 @@ class ServerSessionHandler(SessionHandler):
amount of Sessions due to multi-playing). amount of Sessions due to multi-playing).
""" """
return list(set(session.account for session in self.values() if session.logged_in and session.account)) return list(set(session.account for session in self.values()
if session.logged_in and session.account))
def session_from_sessid(self, sessid): def session_from_sessid(self, sessid):
""" """
@ -737,13 +740,17 @@ class ServerSessionHandler(SessionHandler):
def sessions_from_csessid(self, csessid): def sessions_from_csessid(self, csessid):
""" """
Given a cliend identification hash (for session types that offer them) return all sessions with Given a client identification hash (for session types that offer them)
a matching hash. return all sessions with a matching hash.
Args Args
csessid (str): The session hash csessid (str): The session hash.
Returns:
sessions (list): The sessions with matching .csessid, if any.
""" """
if csessid:
return []
return [session for session in self.values() return [session for session in self.values()
if session.csessid and session.csessid == csessid] if session.csessid and session.csessid == csessid]

View file

@ -68,13 +68,14 @@ def general_context(request):
is automatically added to context of all views. is automatically added to context of all views.
""" """
account = None account = None
if request.user.is_authenticated: account = request.user if request.user.is_authenticated:
account = request.user
puppet = None puppet = None
if account and request.session.get('puppet'): if account and request.session.get('puppet'):
pk = int(request.session.get('puppet')) pk = int(request.session.get('puppet'))
puppet = next((x for x in account.characters if x.pk == pk), None) puppet = next((x for x in account.characters if x.pk == pk), None)
return { return {
'account': account, 'account': account,
'puppet': puppet, 'puppet': puppet,

View file

@ -2,6 +2,7 @@ from django.contrib.auth import authenticate, login
from evennia.accounts.models import AccountDB from evennia.accounts.models import AccountDB
from evennia.utils import logger from evennia.utils import logger
class SharedLoginMiddleware(object): class SharedLoginMiddleware(object):
""" """
Handle the shared login between website and webclient. Handle the shared login between website and webclient.
@ -10,47 +11,47 @@ class SharedLoginMiddleware(object):
def __init__(self, get_response): def __init__(self, get_response):
# One-time configuration and initialization. # One-time configuration and initialization.
self.get_response = get_response self.get_response = get_response
def __call__(self, request): def __call__(self, request):
# Code to be executed for each request before # Code to be executed for each request before
# the view (and later middleware) are called. # the view (and later middleware) are called.
# Synchronize credentials between webclient and website # Synchronize credentials between webclient and website
# Must be performed *before* rendering the view (issue #1723) # Must be performed *before* rendering the view (issue #1723)
self.make_shared_login(request) self.make_shared_login(request)
# Process view # Process view
response = self.get_response(request) response = self.get_response(request)
# Code to be executed for each request/response after # Code to be executed for each request/response after
# the view is called. # the view is called.
# Return processed view # Return processed view
return response return response
@classmethod @classmethod
def make_shared_login(cls, request): def make_shared_login(cls, request):
csession = request.session csession = request.session
account = request.user account = request.user
website_uid = csession.get("website_authenticated_uid", None) website_uid = csession.get("website_authenticated_uid", None)
webclient_uid = csession.get("webclient_authenticated_uid", None) webclient_uid = csession.get("webclient_authenticated_uid", None)
if not csession.session_key: if not csession.session_key:
# this is necessary to build the sessid key # this is necessary to build the sessid key
csession.save() csession.save()
if account.is_authenticated: if account.is_authenticated:
# Logged into website # Logged into website
if not website_uid: if website_uid is None:
# fresh website login (just from login page) # fresh website login (just from login page)
csession["website_authenticated_uid"] = account.id csession["website_authenticated_uid"] = account.id
if webclient_uid is None: if webclient_uid is None:
# auto-login web client # auto-login web client
csession["webclient_authenticated_uid"] = account.id csession["webclient_authenticated_uid"] = account.id
elif webclient_uid: elif webclient_uid:
# Not logged into website, but logged into webclient # Not logged into website, but logged into webclient
if not website_uid: if website_uid is None:
csession["website_authenticated_uid"] = account.id csession["website_authenticated_uid"] = account.id
account = AccountDB.objects.get(id=webclient_uid) account = AccountDB.objects.get(id=webclient_uid)
try: try:

View file

@ -2,7 +2,7 @@
# general # general
django >= 2.1, < 2.2 django >= 2.1, < 2.2
twisted >= 18.0.0, < 19.0.0 twisted >= 19.2.1, < 20.0.0
pillow == 5.2.0 pillow == 5.2.0
pytz pytz
future >= 0.15.2 future >= 0.15.2

View file

@ -5,7 +5,7 @@ pypiwin32
# general # general
django >= 2.1, < 2.2 django >= 2.1, < 2.2
twisted >= 18.0.0, < 19.0.0 twisted >= 19.2.1, < 20.0.0
pillow == 5.2.0 pillow == 5.2.0
pytz pytz
future >= 0.15.2 future >= 0.15.2