Add missing CONNECTION_THROTTLE, cleanup

This commit is contained in:
Griatch 2018-09-25 21:35:49 +02:00
commit 0ef9e4bf09
3 changed files with 195 additions and 71 deletions

View file

@ -4,13 +4,13 @@ Commands that are available from the connect screen.
import re
import time
import datetime
from collections import defaultdict
from random import getrandbits
from django.conf import settings
from django.contrib.auth import authenticate
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.server.models import ServerConfig
from evennia.server.throttle import Throttle
from evennia.comms.models import ChannelDB
from evennia.server.sessionhandler import SESSIONS
@ -26,57 +26,10 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate",
MULTISESSION_MODE = settings.MULTISESSION_MODE
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
# Helper function to throttle failed connection attempts.
# This can easily be used to limit account creation too,
# (just supply a different storage dictionary), but this
# would also block dummyrunner, so it's not added as default.
_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
# Create throttles for too many connections, account-creations and login attempts
CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60)
CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60)
LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60)
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.
"""
# check for too many login errors too quick.
if _throttle(session, maxlim=5, timeout=5 * 60):
# timeout is 5 minutes.
address = session.address
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")
return None
@ -161,7 +117,7 @@ def create_normal_account(session, name, password):
# No accountname or password match
session.msg("Incorrect login information given.")
# this just updates the throttle
_throttle(session)
LOGIN_THROTTLE.update(address)
# calls account hook for a failed login if possible.
account = AccountDB.objects.get_account_from_name(name)
if account:
@ -171,7 +127,6 @@ def create_normal_account(session, name, password):
# Check IP and/or name bans
bans = ServerConfig.objects.conf("server_bans")
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])):
# this is a banned IP or name!
string = "|rYou have been banned and cannot continue from here." \
@ -211,7 +166,10 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
session = self.caller
# 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.
session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n")
return
@ -234,6 +192,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS):
session.msg("\n\r Usage (without <>): connect <name> <password>")
return
CONNECTION_THROTTLE.update(address)
name, password = parts
account = create_normal_account(session, name, password)
if account:
@ -263,6 +222,15 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
session = self.caller
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
parts = [part.strip() for part in re.split(r"\"", args) if part.strip()]
if len(parts) == 1:
@ -294,7 +262,7 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
string = "\n\r That name is reserved. Please choose another Accountname."
session.msg(string)
return
# Validate password
Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
# Have to create a dummy Account object to check username similarity
@ -326,6 +294,10 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
if MULTISESSION_MODE < 2:
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
_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.
string = "A new account '%s' was created. Welcome!"
if " " in accountname:

View file

@ -23,12 +23,14 @@ try:
from django.utils import unittest
except ImportError:
import unittest
from evennia.server.validators import EvenniaPasswordValidator
from evennia.utils.test_resources import EvenniaTest
from django.test.runner import DiscoverRunner
from evennia.server.throttle import Throttle
from .deprecations import check_errors
@ -65,31 +67,80 @@ class TestDeprecations(TestCase):
"""
Class for testing deprecations.check_errors.
"""
deprecated_settings = ("CMDSET_DEFAULT", "CMDSET_OOC", "BASE_COMM_TYPECLASS", "COMM_TYPECLASS_PATHS",
"CHARACTER_DEFAULT_HOME", "OBJECT_TYPECLASS_PATHS", "SCRIPT_TYPECLASS_PATHS",
"ACCOUNT_TYPECLASS_PATHS", "CHANNEL_TYPECLASS_PATHS", "SEARCH_MULTIMATCH_SEPARATOR",
"TIME_SEC_PER_MIN", "TIME_MIN_PER_HOUR", "TIME_HOUR_PER_DAY", "TIME_DAY_PER_WEEK",
"TIME_WEEK_PER_MONTH", "TIME_MONTH_PER_YEAR")
deprecated_settings = (
"CMDSET_DEFAULT", "CMDSET_OOC", "BASE_COMM_TYPECLASS", "COMM_TYPECLASS_PATHS",
"CHARACTER_DEFAULT_HOME", "OBJECT_TYPECLASS_PATHS", "SCRIPT_TYPECLASS_PATHS",
"ACCOUNT_TYPECLASS_PATHS", "CHANNEL_TYPECLASS_PATHS", "SEARCH_MULTIMATCH_SEPARATOR",
"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):
"""
All settings in deprecated_settings should raise a DeprecationWarning if they exist. WEBSERVER_PORTS
raises an error if the iterable value passed does not have a tuple as its first element.
All settings in deprecated_settings should raise a DeprecationWarning if they exist.
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:
self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting))
# 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):
def test_validator(self):
# Validator returns None on success and ValidationError on failure.
validator = EvenniaPasswordValidator()
# This password should meet Evennia standards.
self.assertFalse(validator.validate('testpassword', user=self.account))
# This password contains illegal characters and should raise an Exception.
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
View 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