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)
Update to Python 3
### Distribution
- Use `python3 -m venv <myenvname>`
- Use `python3 -m pdb <script>` for debugging
- New requirement: Python 3.7 (py2.7 support removed)
- Django 2.1
- Twisted 19.2.1
- Autobahn websockets (remove old tmwx)
- Docker image updated
### Commands
@ -20,6 +22,7 @@ Update to Python 3
that are calculated on the fly.
- `@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).
- Shorter and more informative, dynamic, listing of on-command vars if not setting func() in child command class.
### Web
@ -73,7 +76,7 @@ Update to Python 3
- Prettifies Django 'change password' workflow
- Bugfixes
- 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.
### Prototypes
@ -115,6 +118,8 @@ Update to Python 3
- 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
unix file sorting order.
- Django signals fire for important events: Puppet/Unpuppet, Object create/rename, Login,
Logout, Login fail Disconnect, Account create/rename
### Utils

View file

@ -420,7 +420,9 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
return False
@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.
@ -437,7 +439,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
try:
klass = import_string(validator['NAME'])
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'])
objs.append(klass(**validator.get('OPTIONS', {})))
return objs
@ -473,7 +476,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
"""
errors = []
if ip: ip = str(ip)
if ip:
ip = str(ip)
# See if authentication is currently being throttled
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)
if banned:
# this is a banned IP or name!
errors.append("|rYou have been banned and cannot continue from here." \
"\nIf you feel this ban is in error, please email an admin.|x")
errors.append("|rYou have been banned and cannot continue from here."
"\nIf you feel this ban is in error, please email an admin.|x")
logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip))
LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.')
return None, errors
@ -504,7 +508,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip))
# 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
session = kwargs.get('session', None)
@ -573,7 +578,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
# Disqualify if any check failed
if False in valid:
valid = False
else: valid = True
else:
valid = True
return valid, errors
@ -713,7 +719,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
account.db.FIRST_LOGIN = True
# 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
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,
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)
return result
@ -1447,7 +1454,8 @@ class DefaultGuest(DefaultAccount):
break
if not username:
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
else:
# 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())
"""
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
string = "-" * 50
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
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
string = "-" * 50
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
# 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")
totaltable.align = 'l'
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))
# 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'
dbtotals = ObjectDB.objects.object_totals()
for path, count in dbtotals.items():
@ -466,7 +467,8 @@ class CmdObjects(COMMAND_DEFAULT_CLASS):
# last N table
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'
for obj in objs:
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
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
and store the result on self.
- func(): Performs the actual work.

View file

@ -90,8 +90,11 @@ class PortalSessionHandler(SessionHandler):
if session:
# assign if we are first-connectors
self.latest_sessid += 1
session.sessid = self.latest_sessid
if not session.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
_CONNECTION_QUEUE.appendleft(session)
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
CLOSE_NORMAL = WebSocketServerProtocol.CLOSE_STATUS_CODE_NORMAL
class WebSocketClient(WebSocketServerProtocol, Session):
"""
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
self.init_session("websocket", client_address, self.factory.sessionhandler)
from evennia.utils import logger
try:
csessid = self.http_request_uri.split("?", 1)[1]
except Exception:
logger.log_trace(str(self.__dict__))
csession = self.get_client_session()
csession = self.get_client_session() # this sets self.csessid
csessid = self.csessid
uid = csession and csession.get("webclient_authenticated_uid", None)
if uid:
# the client session is already logged in.
self.uid = uid
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
self.transport.setTcpKeepAlive(1)
# actually do the connection
@ -97,11 +102,19 @@ class WebSocketClient(WebSocketServerProtocol, Session):
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,
# in case anyone wants to expose this functionality later.
#
# sendClose() under autobahn/websocket/interfaces.py
self.sendClose(1000, reason)
self.sendClose(CLOSE_NORMAL, reason)
def onClose(self, wasClean, code=None, reason=None):
"""
@ -111,19 +124,14 @@ class WebSocketClient(WebSocketServerProtocol, Session):
Args:
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.
reason (str or None): Close reason as sent by the WebSocket peer.
"""
csession = self.get_client_session()
if csession:
csession["webclient_authenticated_uid"] = None
csession.save()
self.logged_in = False
self.sessionhandler.disconnect(self)
if code == CLOSE_NORMAL:
self.disconnect(reason)
else:
self.websocket_close_code = code
def onMessage(self, payload, isBinary):
"""

View file

@ -214,7 +214,7 @@ class SessionHandler(dict):
return newdict
elif is_iter(data):
return [_validate(part) for part in data]
elif isinstance(data, (str, bytes )):
elif isinstance(data, (str, bytes)):
data = _utf8(data)
if _INLINEFUNC_ENABLED and not raw and isinstance(self, ServerSessionHandler):
@ -257,9 +257,9 @@ class SessionHandler(dict):
return rkwargs
#------------------------------------------------------------
# ------------------------------------------------------------
# Server-SessionHandler class
#------------------------------------------------------------
# ------------------------------------------------------------
class ServerSessionHandler(SessionHandler):
"""
@ -413,7 +413,7 @@ class ServerSessionHandler(SessionHandler):
# set a watchdog to avoid self.disconnect from deleting
# the session while we are looping over them
self._disconnect_all = True
for session in self.values:
for session in self.values():
session.disconnect()
del self._disconnect_all
@ -443,7 +443,8 @@ class ServerSessionHandler(SessionHandler):
"""
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):
"""
@ -544,7 +545,8 @@ class ServerSessionHandler(SessionHandler):
nsess = len(self.sessions_from_account(session.account)) - 1
sreason = " ({})".format(reason) if reason else ""
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)
if nsess == 0:
@ -670,7 +672,8 @@ class ServerSessionHandler(SessionHandler):
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):
"""
@ -737,13 +740,17 @@ class ServerSessionHandler(SessionHandler):
def sessions_from_csessid(self, csessid):
"""
Given a cliend identification hash (for session types that offer them) return all sessions with
a matching hash.
Given a client identification hash (for session types that offer them)
return all sessions with a matching hash.
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()
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.
"""
account = None
if request.user.is_authenticated: account = request.user
if request.user.is_authenticated:
account = request.user
puppet = None
if account and request.session.get('puppet'):
pk = int(request.session.get('puppet'))
puppet = next((x for x in account.characters if x.pk == pk), None)
return {
'account': account,
'puppet': puppet,

View file

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

View file

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

View file

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