Add missing CONNECTION_THROTTLE, cleanup
This commit is contained in:
commit
0fd36ac335
3 changed files with 195 additions and 71 deletions
|
|
@ -4,13 +4,13 @@ Commands that are available from the connect screen.
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import datetime
|
import datetime
|
||||||
from collections import defaultdict
|
|
||||||
from random import getrandbits
|
from random import getrandbits
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from evennia.accounts.models import AccountDB
|
from evennia.accounts.models import AccountDB
|
||||||
from evennia.objects.models import ObjectDB
|
from evennia.objects.models import ObjectDB
|
||||||
from evennia.server.models import ServerConfig
|
from evennia.server.models import ServerConfig
|
||||||
|
from evennia.server.throttle import Throttle
|
||||||
from evennia.comms.models import ChannelDB
|
from evennia.comms.models import ChannelDB
|
||||||
from evennia.server.sessionhandler import SESSIONS
|
from evennia.server.sessionhandler import SESSIONS
|
||||||
|
|
||||||
|
|
@ -26,57 +26,10 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate",
|
||||||
MULTISESSION_MODE = settings.MULTISESSION_MODE
|
MULTISESSION_MODE = settings.MULTISESSION_MODE
|
||||||
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
|
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
|
||||||
|
|
||||||
# Helper function to throttle failed connection attempts.
|
# Create throttles for too many connections, account-creations and login attempts
|
||||||
# This can easily be used to limit account creation too,
|
CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60)
|
||||||
# (just supply a different storage dictionary), but this
|
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
|
||||||
# would also block dummyrunner, so it's not added as default.
|
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
|
||||||
|
|
||||||
_LATEST_FAILED_LOGINS = defaultdict(list)
|
|
||||||
|
|
||||||
|
|
||||||
def _throttle(session, maxlim=None, timeout=None, storage=_LATEST_FAILED_LOGINS):
|
|
||||||
"""
|
|
||||||
This will check the session's address against the
|
|
||||||
_LATEST_LOGINS dictionary to check they haven't
|
|
||||||
spammed too many fails recently.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session (Session): Session failing
|
|
||||||
maxlim (int): max number of attempts to allow
|
|
||||||
timeout (int): number of timeout seconds after
|
|
||||||
max number of tries has been reached.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
throttles (bool): True if throttling is active,
|
|
||||||
False otherwise.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
If maxlim and/or timeout are set, the function will
|
|
||||||
just do the comparison, not append a new datapoint.
|
|
||||||
|
|
||||||
"""
|
|
||||||
address = session.address
|
|
||||||
if isinstance(address, tuple):
|
|
||||||
address = address[0]
|
|
||||||
now = time.time()
|
|
||||||
if maxlim and timeout:
|
|
||||||
# checking mode
|
|
||||||
latest_fails = storage[address]
|
|
||||||
if latest_fails and len(latest_fails) >= maxlim:
|
|
||||||
# too many fails recently
|
|
||||||
if now - latest_fails[-1] < timeout:
|
|
||||||
# too soon - timeout in play
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# timeout has passed. Reset faillist
|
|
||||||
storage[address] = []
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
# store the time of the latest fail
|
|
||||||
storage[address].append(time.time())
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def create_guest_account(session):
|
def create_guest_account(session):
|
||||||
|
|
@ -149,8 +102,11 @@ def create_normal_account(session, name, password):
|
||||||
account (Account): the account which was created from the name and password.
|
account (Account): the account which was created from the name and password.
|
||||||
"""
|
"""
|
||||||
# check for too many login errors too quick.
|
# check for too many login errors too quick.
|
||||||
if _throttle(session, maxlim=5, timeout=5 * 60):
|
address = session.address
|
||||||
# timeout is 5 minutes.
|
if isinstance(address, tuple):
|
||||||
|
address = address[0]
|
||||||
|
|
||||||
|
if LOGIN_THROTTLE.check(address):
|
||||||
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
|
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -161,7 +117,7 @@ def create_normal_account(session, name, password):
|
||||||
# No accountname or password match
|
# No accountname or password match
|
||||||
session.msg("Incorrect login information given.")
|
session.msg("Incorrect login information given.")
|
||||||
# this just updates the throttle
|
# this just updates the throttle
|
||||||
_throttle(session)
|
LOGIN_THROTTLE.update(address)
|
||||||
# calls account hook for a failed login if possible.
|
# calls account hook for a failed login if possible.
|
||||||
account = AccountDB.objects.get_account_from_name(name)
|
account = AccountDB.objects.get_account_from_name(name)
|
||||||
if account:
|
if account:
|
||||||
|
|
@ -171,7 +127,6 @@ def create_normal_account(session, name, password):
|
||||||
# Check IP and/or name bans
|
# Check IP and/or name bans
|
||||||
bans = ServerConfig.objects.conf("server_bans")
|
bans = ServerConfig.objects.conf("server_bans")
|
||||||
if bans and (any(tup[0] == account.name.lower() for tup in bans) or
|
if bans and (any(tup[0] == account.name.lower() for tup in bans) or
|
||||||
|
|
||||||
any(tup[2].match(session.address) for tup in bans if tup[2])):
|
any(tup[2].match(session.address) for tup in bans if tup[2])):
|
||||||
# this is a banned IP or name!
|
# this is a banned IP or name!
|
||||||
string = "|rYou have been banned and cannot continue from here." \
|
string = "|rYou have been banned and cannot continue from here." \
|
||||||
|
|
@ -211,7 +166,10 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
|
||||||
session = self.caller
|
session = self.caller
|
||||||
|
|
||||||
# check for too many login errors too quick.
|
# check for too many login errors too quick.
|
||||||
if _throttle(session, maxlim=5, timeout=5 * 60, storage=_LATEST_FAILED_LOGINS):
|
address = session.address
|
||||||
|
if isinstance(address, tuple):
|
||||||
|
address = address[0]
|
||||||
|
if CONNECTION_THROTTLE.check(address):
|
||||||
# timeout is 5 minutes.
|
# timeout is 5 minutes.
|
||||||
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
|
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
|
||||||
return
|
return
|
||||||
|
|
@ -234,6 +192,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
|
||||||
session.msg("\n\r Usage (without <>): connect <name> <password>")
|
session.msg("\n\r Usage (without <>): connect <name> <password>")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
CONNECTION_THROTTLE.update(address)
|
||||||
name, password = parts
|
name, password = parts
|
||||||
account = create_normal_account(session, name, password)
|
account = create_normal_account(session, name, password)
|
||||||
if account:
|
if account:
|
||||||
|
|
@ -263,6 +222,15 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
|
||||||
session = self.caller
|
session = self.caller
|
||||||
args = self.args.strip()
|
args = self.args.strip()
|
||||||
|
|
||||||
|
# Rate-limit account creation.
|
||||||
|
address = session.address
|
||||||
|
|
||||||
|
if isinstance(address, tuple):
|
||||||
|
address = address[0]
|
||||||
|
if CREATION_THROTTLE.check(address):
|
||||||
|
session.msg("|RYou are creating too many accounts. Try again in a few minutes.|n")
|
||||||
|
return
|
||||||
|
|
||||||
# extract double quoted parts
|
# extract double quoted parts
|
||||||
parts = [part.strip() for part in re.split(r"\"", args) if part.strip()]
|
parts = [part.strip() for part in re.split(r"\"", args) if part.strip()]
|
||||||
if len(parts) == 1:
|
if len(parts) == 1:
|
||||||
|
|
@ -294,7 +262,7 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
|
||||||
string = "\n\r That name is reserved. Please choose another Accountname."
|
string = "\n\r That name is reserved. Please choose another Accountname."
|
||||||
session.msg(string)
|
session.msg(string)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate password
|
# Validate password
|
||||||
Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
|
Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
|
||||||
# Have to create a dummy Account object to check username similarity
|
# Have to create a dummy Account object to check username similarity
|
||||||
|
|
@ -326,6 +294,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
|
||||||
if MULTISESSION_MODE < 2:
|
if MULTISESSION_MODE < 2:
|
||||||
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
|
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
|
||||||
_create_character(session, new_account, typeclass, default_home, permissions)
|
_create_character(session, new_account, typeclass, default_home, permissions)
|
||||||
|
|
||||||
|
# Update the throttle to indicate a new account was created from this IP
|
||||||
|
CREATION_THROTTLE.update(address)
|
||||||
|
|
||||||
# tell the caller everything went well.
|
# tell the caller everything went well.
|
||||||
string = "A new account '%s' was created. Welcome!"
|
string = "A new account '%s' was created. Welcome!"
|
||||||
if " " in accountname:
|
if " " in accountname:
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,14 @@ try:
|
||||||
from django.utils import unittest
|
from django.utils import unittest
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from evennia.server.validators import EvenniaPasswordValidator
|
from evennia.server.validators import EvenniaPasswordValidator
|
||||||
from evennia.utils.test_resources import EvenniaTest
|
from evennia.utils.test_resources import EvenniaTest
|
||||||
|
|
||||||
from django.test.runner import DiscoverRunner
|
from django.test.runner import DiscoverRunner
|
||||||
|
|
||||||
|
from evennia.server.throttle import Throttle
|
||||||
|
|
||||||
from .deprecations import check_errors
|
from .deprecations import check_errors
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,31 +67,80 @@ class TestDeprecations(TestCase):
|
||||||
"""
|
"""
|
||||||
Class for testing deprecations.check_errors.
|
Class for testing deprecations.check_errors.
|
||||||
"""
|
"""
|
||||||
deprecated_settings = ("CMDSET_DEFAULT", "CMDSET_OOC", "BASE_COMM_TYPECLASS", "COMM_TYPECLASS_PATHS",
|
deprecated_settings = (
|
||||||
"CHARACTER_DEFAULT_HOME", "OBJECT_TYPECLASS_PATHS", "SCRIPT_TYPECLASS_PATHS",
|
"CMDSET_DEFAULT", "CMDSET_OOC", "BASE_COMM_TYPECLASS", "COMM_TYPECLASS_PATHS",
|
||||||
"ACCOUNT_TYPECLASS_PATHS", "CHANNEL_TYPECLASS_PATHS", "SEARCH_MULTIMATCH_SEPARATOR",
|
"CHARACTER_DEFAULT_HOME", "OBJECT_TYPECLASS_PATHS", "SCRIPT_TYPECLASS_PATHS",
|
||||||
"TIME_SEC_PER_MIN", "TIME_MIN_PER_HOUR", "TIME_HOUR_PER_DAY", "TIME_DAY_PER_WEEK",
|
"ACCOUNT_TYPECLASS_PATHS", "CHANNEL_TYPECLASS_PATHS", "SEARCH_MULTIMATCH_SEPARATOR",
|
||||||
"TIME_WEEK_PER_MONTH", "TIME_MONTH_PER_YEAR")
|
"TIME_SEC_PER_MIN", "TIME_MIN_PER_HOUR", "TIME_HOUR_PER_DAY", "TIME_DAY_PER_WEEK",
|
||||||
|
"TIME_WEEK_PER_MONTH", "TIME_MONTH_PER_YEAR")
|
||||||
|
|
||||||
def test_check_errors(self):
|
def test_check_errors(self):
|
||||||
"""
|
"""
|
||||||
All settings in deprecated_settings should raise a DeprecationWarning if they exist. WEBSERVER_PORTS
|
All settings in deprecated_settings should raise a DeprecationWarning if they exist.
|
||||||
raises an error if the iterable value passed does not have a tuple as its first element.
|
WEBSERVER_PORTS raises an error if the iterable value passed does not have a tuple as its
|
||||||
|
first element.
|
||||||
"""
|
"""
|
||||||
for setting in self.deprecated_settings:
|
for setting in self.deprecated_settings:
|
||||||
self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting))
|
self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting))
|
||||||
# test check for WEBSERVER_PORTS having correct value
|
# test check for WEBSERVER_PORTS having correct value
|
||||||
self.assertRaises(DeprecationWarning, check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"]))
|
self.assertRaises(
|
||||||
|
DeprecationWarning,
|
||||||
|
check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"]))
|
||||||
|
|
||||||
|
|
||||||
class ValidatorTest(EvenniaTest):
|
class ValidatorTest(EvenniaTest):
|
||||||
|
|
||||||
def test_validator(self):
|
def test_validator(self):
|
||||||
# Validator returns None on success and ValidationError on failure.
|
# Validator returns None on success and ValidationError on failure.
|
||||||
validator = EvenniaPasswordValidator()
|
validator = EvenniaPasswordValidator()
|
||||||
|
|
||||||
# This password should meet Evennia standards.
|
# This password should meet Evennia standards.
|
||||||
self.assertFalse(validator.validate('testpassword', user=self.account))
|
self.assertFalse(validator.validate('testpassword', user=self.account))
|
||||||
|
|
||||||
# This password contains illegal characters and should raise an Exception.
|
# This password contains illegal characters and should raise an Exception.
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
self.assertRaises(ValidationError, validator.validate, '(#)[#]<>', user=self.account)
|
self.assertRaises(ValidationError, validator.validate, '(#)[#]<>', user=self.account)
|
||||||
|
|
||||||
|
|
||||||
|
class ThrottleTest(EvenniaTest):
|
||||||
|
"""
|
||||||
|
Class for testing the connection/IP throttle.
|
||||||
|
"""
|
||||||
|
def test_throttle(self):
|
||||||
|
ips = ('94.100.176.153', '45.56.148.77', '5.196.1.129')
|
||||||
|
kwargs = {
|
||||||
|
'limit': 5,
|
||||||
|
'timeout': 15 * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
throttle = Throttle(**kwargs)
|
||||||
|
|
||||||
|
for ip in ips:
|
||||||
|
# Throttle should not be engaged by default
|
||||||
|
self.assertFalse(throttle.check(ip))
|
||||||
|
|
||||||
|
# Pretend to fail a bunch of events
|
||||||
|
for x in range(50):
|
||||||
|
obj = throttle.update(ip)
|
||||||
|
self.assertFalse(obj)
|
||||||
|
|
||||||
|
# Next ones should be blocked
|
||||||
|
self.assertTrue(throttle.check(ip))
|
||||||
|
|
||||||
|
for x in range(throttle.cache_size * 2):
|
||||||
|
obj = throttle.update(ip)
|
||||||
|
self.assertFalse(obj)
|
||||||
|
|
||||||
|
# Should still be blocked
|
||||||
|
self.assertTrue(throttle.check(ip))
|
||||||
|
|
||||||
|
# Number of values should be limited by cache size
|
||||||
|
self.assertEqual(throttle.cache_size, len(throttle.get(ip)))
|
||||||
|
|
||||||
|
cache = throttle.get()
|
||||||
|
|
||||||
|
# Make sure there are entries for each IP
|
||||||
|
self.assertEqual(len(ips), len(cache.keys()))
|
||||||
|
|
||||||
|
# There should only be (cache_size * num_ips) total in the Throttle cache
|
||||||
|
self.assertEqual(sum([len(cache[x]) for x in cache.keys()]), throttle.cache_size * len(ips))
|
||||||
|
|
|
||||||
101
evennia/server/throttle.py
Normal file
101
evennia/server/throttle.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
import time
|
||||||
|
|
||||||
|
class Throttle(object):
|
||||||
|
"""
|
||||||
|
Keeps a running count of failed actions per IP address.
|
||||||
|
|
||||||
|
Available methods indicate whether or not the number of failures exceeds a
|
||||||
|
particular threshold.
|
||||||
|
|
||||||
|
This version of the throttle is usable by both the terminal server as well
|
||||||
|
as the web server, imposes limits on memory consumption by using deques
|
||||||
|
with length limits instead of open-ended lists, and removes sparse keys when
|
||||||
|
no recent failures have been recorded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
error_msg = 'Too many failed attempts; you must wait a few minutes before trying again.'
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Allows setting of throttle parameters.
|
||||||
|
|
||||||
|
Kwargs:
|
||||||
|
limit (int): Max number of failures before imposing limiter
|
||||||
|
timeout (int): number of timeout seconds after
|
||||||
|
max number of tries has been reached.
|
||||||
|
cache_size (int): Max number of attempts to record per IP within a
|
||||||
|
rolling window; this is NOT the same as the limit after which
|
||||||
|
the throttle is imposed!
|
||||||
|
"""
|
||||||
|
self.storage = defaultdict(deque)
|
||||||
|
self.cache_size = self.limit = kwargs.get('limit', 5)
|
||||||
|
self.timeout = kwargs.get('timeout', 5 * 60)
|
||||||
|
|
||||||
|
def get(self, ip=None):
|
||||||
|
"""
|
||||||
|
Convenience function that returns the storage table, or part of.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip (str, optional): IP address of requestor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
storage (dict): When no IP is provided, returns a dict of all
|
||||||
|
current IPs being tracked and the timestamps of their recent
|
||||||
|
failures.
|
||||||
|
timestamps (deque): When an IP is provided, returns a deque of
|
||||||
|
timestamps of recent failures only for that IP.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if ip: return self.storage.get(ip, deque(maxlen=self.cache_size))
|
||||||
|
else: return self.storage
|
||||||
|
|
||||||
|
def update(self, ip):
|
||||||
|
"""
|
||||||
|
Store the time of the latest failure/
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip (str): IP address of requestor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Enforce length limits
|
||||||
|
if not self.storage[ip].maxlen:
|
||||||
|
self.storage[ip] = deque(maxlen=self.cache_size)
|
||||||
|
|
||||||
|
self.storage[ip].append(time.time())
|
||||||
|
|
||||||
|
def check(self, ip):
|
||||||
|
"""
|
||||||
|
This will check the session's address against the
|
||||||
|
storage dictionary to check they haven't spammed too many
|
||||||
|
fails recently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip (str): IP address of requestor
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
throttled (bool): True if throttling is active,
|
||||||
|
False otherwise.
|
||||||
|
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
ip = str(ip)
|
||||||
|
|
||||||
|
# checking mode
|
||||||
|
latest_fails = self.storage[ip]
|
||||||
|
if latest_fails and len(latest_fails) >= self.limit:
|
||||||
|
# too many fails recently
|
||||||
|
if now - latest_fails[-1] < self.timeout:
|
||||||
|
# too soon - timeout in play
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# timeout has passed. clear faillist
|
||||||
|
del(self.storage[ip])
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue