diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 0b181538a1..8ae700e2c1 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -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 ") 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: diff --git a/evennia/server/tests.py b/evennia/server/tests.py index bb1fae4af5..4d7f271417 100644 --- a/evennia/server/tests.py +++ b/evennia/server/tests.py @@ -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) \ No newline at end of file + 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)) diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py new file mode 100644 index 0000000000..56c88c63f2 --- /dev/null +++ b/evennia/server/throttle.py @@ -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 + + \ No newline at end of file