diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a31aecb97..da326f766f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,30 @@ - Add the Portal uptime to the `@time` command. - Make the `@link` command first make a local search before a global search. +### Typeclasses + +- Add new methods on all typeclasses, useful specifically for viewing the object in the web/admin: + + `web_get_admin_url()`: Returns a path that, if followed, will display the object in the Admin backend. + + `web_get_create_url()`: Returns a path for a view allowing the creation of new instances of this object. + + `web_get_absolute_url()`: Django construct; returns a path that should display the object in a DetailView. + + `web_get_update_url()`: Returns a path that should display the object in an UpdateView. + + `web_get_delete_url()`: Returns a path that should display the object in a DeleteView. +- All typeclasses has new helper class method `create`, which encompasses useful functionality + that used to be embedded for example in the respective `@create` or `@connect` commands. +- DefaultAccount now has new class methods implementing many things that used to be in unloggedin + commands (these can now be customized on the class instead): + + `is_banned()`: Checks if a given username or IP is banned. + + `get_username_validators`: Return list of validators for username validation (see + `settings.AUTH_USERNAME_VALIDATORS`) + + `authenticate`: Method to check given username/password. + + `normalize_username`: Normalizes names so you can't fake names with similar-looking Unicode + chars. + + `validate_username`: Mechanism for validating a username. + + `validate_password`: Mechanism for validating a password. + + `set_password`: Apply password to account, using validation checks. + + + ### Utils - Added more unit tests. @@ -34,7 +58,7 @@ to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will return Server to normal daemon operation. - For validating passwords, use safe Django password-validation backend instead of custom Evennia one. -- Alias `evennia restart` to mean the same as `evennia reload`. +- Alias `evennia restart` to mean the same as `evennia reload`. ### Prototype changes diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 3eab69a3ce..3fed8dc25e 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -10,19 +10,22 @@ character object, so you should customize that instead for most things). """ - +import re import time from django.conf import settings -from django.contrib.auth import password_validation -from django.core.exceptions import ValidationError +from django.contrib.auth import authenticate, password_validation +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils import timezone +from django.utils.module_loading import import_string from evennia.typeclasses.models import TypeclassBase from evennia.accounts.manager import AccountManager from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.comms.models import ChannelDB from evennia.commands import cmdhandler -from evennia.utils import logger +from evennia.server.models import ServerConfig +from evennia.server.throttle import Throttle +from evennia.utils import class_from_module, create, logger from evennia.utils.utils import (lazy_property, to_str, make_iter, to_unicode, is_iter, variable_from_module) @@ -32,6 +35,7 @@ from evennia.commands.cmdsethandler import CmdSetHandler from django.utils.translation import ugettext as _ from future.utils import with_metaclass +from random import getrandbits __all__ = ("DefaultAccount",) @@ -43,6 +47,9 @@ _MAX_NR_CHARACTERS = settings.MAX_NR_CHARACTERS _CMDSET_ACCOUNT = settings.CMDSET_ACCOUNT _CONNECT_CHANNEL = None +# Create throttles for too many account-creations and login attempts +CREATION_THROTTLE = Throttle(limit=2, timeout=10 * 60) +LOGIN_THROTTLE = Throttle(limit=5, timeout=5 * 60) class AccountSessionHandler(object): """ @@ -359,6 +366,189 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): puppet = property(__get_single_puppet) # utility methods + @classmethod + def is_banned(cls, **kwargs): + """ + Checks if a given username or IP is banned. + + Kwargs: + ip (str, optional): IP address. + username (str, optional): Username. + + Returns: + is_banned (bool): Whether either is banned or not. + + """ + + ip = kwargs.get('ip', '').strip() + username = kwargs.get('username', '').lower().strip() + + # Check IP and/or name bans + bans = ServerConfig.objects.conf("server_bans") + if bans and (any(tup[0] == username for tup in bans if username) or + any(tup[2].match(ip) for tup in bans if ip and tup[2])): + return True + + return False + + @classmethod + def get_username_validators(cls, validator_config=getattr(settings, 'AUTH_USERNAME_VALIDATORS', [])): + """ + Retrieves and instantiates validators for usernames. + + Args: + validator_config (list): List of dicts comprising the battery of + validators to apply to a username. + + Returns: + validators (list): List of instantiated Validator objects. + """ + + objs = [] + for validator in validator_config: + try: + klass = import_string(validator['NAME']) + except ImportError: + 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 + + @classmethod + def authenticate(cls, username, password, ip='', **kwargs): + """ + Checks the given username/password against the database to see if the + credentials are valid. + + Note that this simply checks credentials and returns a valid reference + to the user-- it does not log them in! + + To finish the job: + After calling this from a Command, associate the account with a Session: + - session.sessionhandler.login(session, account) + + ...or after calling this from a View, associate it with an HttpRequest: + - django.contrib.auth.login(account, request) + + Args: + username (str): Username of account + password (str): Password of account + ip (str, optional): IP address of client + + Kwargs: + session (Session, optional): Session requesting authentication + + Returns: + account (DefaultAccount, None): Account whose credentials were + provided if not banned. + errors (list): Error messages of any failures. + + """ + errors = [] + if ip: ip = str(ip) + + # See if authentication is currently being throttled + if ip and LOGIN_THROTTLE.check(ip): + errors.append('Too many login failures; please try again in a few minutes.') + + # With throttle active, do not log continued hits-- it is a + # waste of storage and can be abused to make your logs harder to + # read and/or fill up your disk. + return None, errors + + # Check IP and/or name bans + 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") + logger.log_sec('Authentication Denied (Banned): %s (IP: %s).' % (username, ip)) + LOGIN_THROTTLE.update(ip, 'Too many sightings of banned artifact.') + return None, errors + + # Authenticate and get Account object + account = authenticate(username=username, password=password) + if not account: + # User-facing message + errors.append('Username and/or password is incorrect.') + + # Log auth failures while throttle is inactive + logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip)) + + # Update throttle + if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures.') + + # Try to call post-failure hook + session = kwargs.get('session', None) + if session: + account = AccountDB.objects.get_account_from_name(username) + if account: + account.at_failed_login(session) + + return None, errors + + # Account successfully authenticated + logger.log_sec('Authentication Success: %s (IP: %s).' % (account, ip)) + return account, errors + + @classmethod + def normalize_username(cls, username): + """ + Django: Applies NFKC Unicode normalization to usernames so that visually + identical characters with different Unicode code points are considered + identical. + + (This deals with the Turkish "i" problem and similar + annoyances. Only relevant if you go out of your way to allow Unicode + usernames though-- Evennia accepts ASCII by default.) + + In this case we're simply piggybacking on this feature to apply + additional normalization per Evennia's standards. + """ + username = super(DefaultAccount, cls).normalize_username(username) + + # strip excessive spaces in accountname + username = re.sub(r"\s+", " ", username).strip() + + return username + + @classmethod + def validate_username(cls, username): + """ + Checks the given username against the username validator associated with + Account objects, and also checks the database to make sure it is unique. + + Args: + username (str): Username to validate + + Returns: + valid (bool): Whether or not the password passed validation + errors (list): Error messages of any failures + + """ + valid = [] + errors = [] + + # Make sure we're at least using the default validator + validators = cls.get_username_validators() + if not validators: + validators = [cls.username_validator] + + # Try username against all enabled validators + for validator in validators: + try: + valid.append(not validator(username)) + except ValidationError as e: + valid.append(False) + errors.extend(e.messages) + + # Disqualify if any check failed + if False in valid: + valid = False + else: valid = True + + return valid, errors + @classmethod def validate_password(cls, password, account=None): """ @@ -416,9 +606,136 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): if error: raise error super(DefaultAccount, self).set_password(password) - logger.log_info("Password succesfully changed for %s." % self) + logger.log_sec("Password successfully changed for %s." % self) self.at_password_change() + @classmethod + def create(cls, *args, **kwargs): + """ + Creates an Account (or Account/Character pair for MULTISESSION_MODE<2) + with default (or overridden) permissions and having joined them to the + appropriate default channels. + + Kwargs: + username (str): Username of Account owner + password (str): Password of Account owner + email (str, optional): Email address of Account owner + ip (str, optional): IP address of requesting connection + guest (bool, optional): Whether or not this is to be a Guest account + + permissions (str, optional): Default permissions for the Account + typeclass (str, optional): Typeclass to use for new Account + character_typeclass (str, optional): Typeclass to use for new char + when applicable. + + Returns: + account (Account): Account if successfully created; None if not + errors (list): List of error messages in string form + + """ + + account = None + errors = [] + + username = kwargs.get('username') + password = kwargs.get('password') + email = kwargs.get('email', '').strip() + guest = kwargs.get('guest', False) + + permissions = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT) + typeclass = kwargs.get('typeclass', settings.BASE_ACCOUNT_TYPECLASS) + + ip = kwargs.get('ip', '') + if ip and CREATION_THROTTLE.check(ip): + errors.append("You are creating too many accounts. Please log into an existing account.") + return None, errors + + # Normalize username + username = cls.normalize_username(username) + + # Validate username + if not guest: + valid, errs = cls.validate_username(username) + if not valid: + # this echoes the restrictions made by django's auth + # module (except not allowing spaces, for convenience of + # logging in). + errors.extend(errs) + return None, errors + + # Validate password + # Have to create a dummy Account object to check username similarity + valid, errs = cls.validate_password(password, account=cls(username=username)) + if not valid: + errors.extend(errs) + return None, errors + + # Check IP and/or name bans + banned = cls.is_banned(username=username, ip=ip) + if banned: + # this is a banned IP or name! + string = "|rYou have been banned and cannot continue from here." \ + "\nIf you feel this ban is in error, please email an admin.|x" + errors.append(string) + return None, errors + + # everything's ok. Create the new account account. + try: + try: + account = create.create_account(username, email, password, permissions=permissions, typeclass=typeclass) + logger.log_sec('Account Created: %s (IP: %s).' % (account, ip)) + + except Exception as e: + errors.append("There was an error creating the Account. If this problem persists, contact an admin.") + logger.log_trace() + return None, errors + + # This needs to be set so the engine knows this account is + # logging in for the first time. (so it knows to call the right + # hooks during login later) + account.db.FIRST_LOGIN = True + + # Record IP address of creation, if available + 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"]) + if not pchannel or not pchannel.connect(account): + string = "New account '%s' could not connect to public channel!" % account.key + errors.append(string) + logger.log_err(string) + + if account and settings.MULTISESSION_MODE < 2: + # Load the appropriate Character class + character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_TYPECLASS) + character_home = kwargs.get('home') + Character = class_from_module(character_typeclass) + + # Create the character + character, errs = Character.create( + account.key, account, ip=ip, typeclass=character_typeclass, + permissions=permissions, home=character_home + ) + errors.extend(errs) + + if character: + # Update playable character list + account.db._playable_characters.append(character) + + # We need to set this to have @ic auto-connect to this character + account.db._last_puppet = character + + except Exception: + # We are in the middle between logged in and -not, so we have + # to handle tracebacks ourselves at this point. If we don't, + # we won't see any errors at all. + errors.append("An error occurred. Please e-mail an admin if the problem persists.") + logger.log_trace() + + # Update the throttle to indicate a new account was created from this IP + if ip and not guest: CREATION_THROTTLE.update(ip, 'Too many accounts being created.') + return account, errors + def delete(self, *args, **kwargs): """ Deletes the account permanently. @@ -1068,6 +1385,78 @@ class DefaultGuest(DefaultAccount): their characters are deleted after disconnection. """ + @classmethod + def create(cls, **kwargs): + """ + Forwards request to cls.authenticate(); returns a DefaultGuest object + if one is available for use. + """ + return cls.authenticate(**kwargs) + + @classmethod + def authenticate(cls, **kwargs): + """ + Gets or creates a Guest account object. + + Kwargs: + ip (str, optional): IP address of requestor; used for ban checking, + throttling and logging + + Returns: + account (Object): Guest account object, if available + errors (list): List of error messages accrued during this request. + + """ + errors = [] + account = None + username = None + ip = kwargs.get('ip', '').strip() + + # check if guests are enabled. + if not settings.GUEST_ENABLED: + errors.append('Guest accounts are not enabled on this server.') + return None, errors + + try: + # Find an available guest name. + for name in settings.GUEST_LIST: + if not AccountDB.objects.filter(username__iexact=name).count(): + username = name + 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.') + return None, errors + else: + # build a new account with the found guest username + password = "%016x" % getrandbits(64) + home = settings.GUEST_HOME + permissions = settings.PERMISSION_GUEST_DEFAULT + typeclass = settings.BASE_GUEST_TYPECLASS + + # Call parent class creator + account, errs = super(DefaultGuest, cls).create( + guest=True, + username=username, + password=password, + permissions=permissions, + typeclass=typeclass, + home=home, + ip=ip, + ) + errors.extend(errs) + return account, errors + + except Exception as e: + # We are in the middle between logged in and -not, so we have + # to handle tracebacks ourselves at this point. If we don't, + # we won't see any errors at all. + errors.append("An error occurred. Please e-mail an admin if the problem persists.") + logger.log_trace() + return None, errors + + return account, errors + def at_post_login(self, session=None, **kwargs): """ In theory, guests only have one character regardless of which diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 084e8a6a66..1d31e93e2c 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -1,12 +1,16 @@ +# -*- coding: utf-8 -*- + from mock import Mock, MagicMock from random import randint from unittest import TestCase from django.test import override_settings from evennia.accounts.accounts import AccountSessionHandler -from evennia.accounts.accounts import DefaultAccount -from evennia.utils import create +from evennia.accounts.accounts import DefaultAccount, DefaultGuest from evennia.utils.test_resources import EvenniaTest +from evennia.utils import create + +from django.conf import settings class TestAccountSessionHandler(TestCase): @@ -56,6 +60,107 @@ class TestAccountSessionHandler(TestCase): "Check count method" self.assertEqual(self.handler.count(), len(self.handler.get())) +class TestDefaultGuest(EvenniaTest): + "Check DefaultGuest class" + + ip = '212.216.134.22' + + def test_authenticate(self): + # Guest account should not be permitted + account, errors = DefaultGuest.authenticate(ip=self.ip) + self.assertFalse(account, 'Guest account was created despite being disabled.') + + settings.GUEST_ENABLED = True + settings.GUEST_LIST = ['bruce_wayne'] + + # Create a guest account + account, errors = DefaultGuest.authenticate(ip=self.ip) + self.assertTrue(account, 'Guest account should have been created.') + + # Create a second guest account + account, errors = DefaultGuest.authenticate(ip=self.ip) + self.assertFalse(account, 'Two guest accounts were created with a single entry on the guest list!') + + settings.GUEST_ENABLED = False + +class TestDefaultAccountAuth(EvenniaTest): + + def setUp(self): + super(TestDefaultAccountAuth, self).setUp() + + self.password = "testpassword" + self.account.delete() + self.account = create.create_account("TestAccount%s" % randint(100000, 999999), email="test@test.com", password=self.password, typeclass=DefaultAccount) + + def test_authentication(self): + "Confirm Account authentication method is authenticating/denying users." + # Valid credentials + obj, errors = DefaultAccount.authenticate(self.account.name, self.password) + self.assertTrue(obj, 'Account did not authenticate given valid credentials.') + + # Invalid credentials + obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy') + self.assertFalse(obj, 'Account authenticated using invalid credentials.') + + def test_create(self): + "Confirm Account creation is working as expected." + # Create a normal account + account, errors = DefaultAccount.create(username='ziggy', password='stardust11') + self.assertTrue(account, 'New account should have been created.') + + # Try creating a duplicate account + account2, errors = DefaultAccount.create(username='Ziggy', password='starman11') + self.assertFalse(account2, 'Duplicate account name should not have been allowed.') + account.delete() + + def test_throttle(self): + "Confirm throttle activates on too many failures." + for x in xrange(20): + obj, errors = DefaultAccount.authenticate(self.account.name, 'xyzzy', ip='12.24.36.48') + self.assertFalse(obj, 'Authentication was provided a bogus password; this should NOT have returned an account!') + + self.assertTrue('too many login failures' in errors[-1].lower(), 'Failed logins should have been throttled.') + + def test_username_validation(self): + "Check username validators deny relevant usernames" + # Should not accept Unicode by default, lest users pick names like this + result, error = DefaultAccount.validate_username('¯\_(ツ)_/¯') + self.assertFalse(result, "Validator allowed kanji in username.") + + # Should not allow duplicate username + result, error = DefaultAccount.validate_username(self.account.name) + self.assertFalse(result, "Duplicate username should not have passed validation.") + + # Should not allow username too short + result, error = DefaultAccount.validate_username('xx') + self.assertFalse(result, "2-character username passed validation.") + + def test_password_validation(self): + "Check password validators deny bad passwords" + + account = create.create_account("TestAccount%s" % randint(100000, 999999), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'): + self.assertFalse(account.validate_password(bad, account=self.account)[0]) + + "Check validators allow sufficiently complex passwords" + for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"): + self.assertTrue(account.validate_password(better, account=self.account)[0]) + account.delete() + + def test_password_change(self): + "Check password setting and validation is working as expected" + account = create.create_account("TestAccount%s" % randint(100000, 999999), + email="test@test.com", password="testpassword", typeclass=DefaultAccount) + + from django.core.exceptions import ValidationError + # Try setting some bad passwords + for bad in ('', '#', 'TestAccount', 'password'): + self.assertRaises(ValidationError, account.set_password, bad) + + # Try setting a better password (test for False; returns None on success) + self.assertFalse(account.set_password('Mxyzptlk')) + account.delete() class TestDefaultAccount(TestCase): "Check DefaultAccount class" @@ -65,44 +170,6 @@ class TestDefaultAccount(TestCase): self.s1.puppet = None self.s1.sessid = 0 - def test_absolute_url(self): - "Get URL for account detail page on website" - self.account = create.create_account("TestAccount%s" % randint(100000, 999999), - email="test@test.com", password="testpassword", typeclass=DefaultAccount) - self.assertTrue(self.account.web_get_detail_url()) - - def test_admin_url(self): - "Get object's URL for access via Admin pane" - self.account = create.create_account("TestAccount%s" % randint(100000, 999999), - email="test@test.com", password="testpassword", typeclass=DefaultAccount) - self.assertTrue(self.account.web_get_admin_url()) - self.assertTrue(self.account.web_get_admin_url() != '#') - - def test_password_validation(self): - "Check password validators deny bad passwords" - - self.account = create.create_account("TestAccount%s" % randint(0, 9), - email="test@test.com", password="testpassword", typeclass=DefaultAccount) - for bad in ('', '123', 'password', 'TestAccount', '#', 'xyzzy'): - self.assertFalse(self.account.validate_password(bad, account=self.account)[0]) - - "Check validators allow sufficiently complex passwords" - for better in ('Mxyzptlk', "j0hn, i'M 0n1y d4nc1nG"): - self.assertTrue(self.account.validate_password(better, account=self.account)[0]) - - def test_password_change(self): - "Check password setting and validation is working as expected" - self.account = create.create_account("TestAccount%s" % randint(0, 9), - email="test@test.com", password="testpassword", typeclass=DefaultAccount) - - from django.core.exceptions import ValidationError - # Try setting some bad passwords - for bad in ('', '#', 'TestAccount', 'password'): - self.assertRaises(ValidationError, self.account.set_password, bad) - - # Try setting a better password (test for False; returns None on success) - self.assertFalse(self.account.set_password('Mxyzptlk')) - def test_puppet_object_no_object(self): "Check puppet_object method called with no object param" @@ -221,5 +288,4 @@ class TestAccountPuppetDeletion(EvenniaTest): # See what happens when we delete char1. self.char1.delete() # Playable char list should be empty. - self.assertFalse(self.account.db._playable_characters, - 'Playable character list is not empty! %s' % self.account.db._playable_characters) + self.assertFalse(self.account.db._playable_characters, 'Playable character list is not empty! %s' % self.account.db._playable_characters) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 8ae700e2c1..607c95439b 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -2,19 +2,13 @@ Commands that are available from the connect screen. """ import re -import time import datetime -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 -from evennia.utils import create, logger, utils, gametime +from evennia.utils import class_from_module, create, logger, utils, gametime from evennia.commands.cmdhandler import CMD_LOGINSTART COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -26,11 +20,6 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate", MULTISESSION_MODE = settings.MULTISESSION_MODE CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE -# 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): """ @@ -44,49 +33,20 @@ def create_guest_account(session): the boolean is whether guest accounts are enabled at all. the Account which was created from an available guest name. """ - # check if guests are enabled. - if not settings.GUEST_ENABLED: - return False, None + enabled = settings.GUEST_ENABLED + address = session.address - # Check IP bans. - bans = ServerConfig.objects.conf("server_bans") - if bans and any(tup[2].match(session.address) for tup in bans if tup[2]): - # this is a banned IP! - string = "|rYou have been banned and cannot continue from here." \ - "\nIf you feel this ban is in error, please email an admin.|x" - session.msg(string) - session.sessionhandler.disconnect(session, "Good bye! Disconnecting.") - return True, None + # Get account class + Guest = class_from_module(settings.BASE_GUEST_TYPECLASS) - try: - # Find an available guest name. - accountname = None - for name in settings.GUEST_LIST: - if not AccountDB.objects.filter(username__iexact=accountname).count(): - accountname = name - break - if not accountname: - session.msg("All guest accounts are in use. Please try again later.") - return True, None - else: - # build a new account with the found guest accountname - password = "%016x" % getrandbits(64) - home = ObjectDB.objects.get_id(settings.GUEST_HOME) - permissions = settings.PERMISSION_GUEST_DEFAULT - typeclass = settings.BASE_CHARACTER_TYPECLASS - ptypeclass = settings.BASE_GUEST_TYPECLASS - new_account = _create_account(session, accountname, password, permissions, ptypeclass) - if new_account: - _create_character(session, new_account, typeclass, home, permissions) - return True, new_account - - except Exception: - # We are in the middle between logged in and -not, so we have - # to handle tracebacks ourselves at this point. If we don't, - # we won't see any errors at all. - session.msg("An error occurred. Please e-mail an admin if the problem persists.") - logger.log_trace() - raise + # Get an available guest account + # authenticate() handles its own throttling + account, errors = Guest.authenticate(ip=address) + if account: + return enabled, account + else: + session.msg("|R%s|n" % '\n'.join(errors)) + return enabled, None def create_normal_account(session, name, password): @@ -101,38 +61,17 @@ def create_normal_account(session, name, password): Returns: account (Account): the account which was created from the name and password. """ - # check for too many login errors too quick. - address = session.address - if isinstance(address, tuple): - address = address[0] + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) - if LOGIN_THROTTLE.check(address): - session.msg("|RYou made too many connection attempts. Try again in a few minutes.|n") - return None + address = session.address # Match account name and check password - account = authenticate(username=name, password=password) - + # authenticate() handles all its own throttling + account, errors = Account.authenticate(username=name, password=password, ip=address, session=session) if not account: # No accountname or password match - session.msg("Incorrect login information given.") - # this just updates the throttle - LOGIN_THROTTLE.update(address) - # calls account hook for a failed login if possible. - account = AccountDB.objects.get_account_from_name(name) - if account: - account.at_failed_login(session) - return None - - # 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." \ - "\nIf you feel this ban is in error, please email an admin.|x" - session.msg(string) - session.sessionhandler.disconnect(session, "Good bye! Disconnecting.") + session.msg("|R%s|n" % '\n'.join(errors)) return None return account @@ -164,15 +103,7 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): there is no object yet before the account has logged in) """ session = self.caller - - # check for too many login errors too quick. 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 args = self.args # extract double quote parts @@ -180,23 +111,33 @@ class CmdUnconnectedConnect(COMMAND_DEFAULT_CLASS): if len(parts) == 1: # this was (hopefully) due to no double quotes being found, or a guest login parts = parts[0].split(None, 1) + # Guest login if len(parts) == 1 and parts[0].lower() == "guest": - enabled, new_account = create_guest_account(session) - if new_account: - session.sessionhandler.login(session, new_account) - if enabled: + # Get Guest typeclass + Guest = class_from_module(settings.BASE_GUEST_TYPECLASS) + + account, errors = Guest.authenticate(ip=address) + if account: + session.sessionhandler.login(session, account) + return + else: + session.msg("|R%s|n" % '\n'.join(errors)) return if len(parts) != 2: session.msg("\n\r Usage (without <>): connect ") return - CONNECTION_THROTTLE.update(address) + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + name, password = parts - account = create_normal_account(session, name, password) + account, errors = Account.authenticate(username=name, password=password, ip=address, session=session) if account: session.sessionhandler.login(session, account) + else: + session.msg("|R%s|n" % '\n'.join(errors)) class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): @@ -222,14 +163,10 @@ 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 + # Get account class + Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) # extract double quoted parts parts = [part.strip() for part in re.split(r"\"", args) if part.strip()] @@ -241,77 +178,21 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS): "\nIf or contains spaces, enclose it in double quotes." session.msg(string) return - accountname, password = parts - # sanity checks - if not re.findall(r"^[\w. @+\-']+$", accountname) or not (0 < len(accountname) <= 30): - # this echoes the restrictions made by django's auth - # module (except not allowing spaces, for convenience of - # logging in). - string = "\n\r Accountname can max be 30 characters or fewer. Letters, spaces, digits and @/./+/-/_/' only." - session.msg(string) - return - # strip excessive spaces in accountname - accountname = re.sub(r"\s+", " ", accountname).strip() - if AccountDB.objects.filter(username__iexact=accountname): - # account already exists (we also ignore capitalization here) - session.msg("Sorry, there is already an account with the name '%s'." % accountname) - return - # Reserve accountnames found in GUEST_LIST - if settings.GUEST_LIST and accountname.lower() in (guest.lower() for guest in settings.GUEST_LIST): - 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 - valid, error = Account.validate_password(password, account=Account(username=accountname)) - if error: - errors = [e for suberror in error.messages for e in error.messages] - string = "\n".join(errors) - session.msg(string) - return - - # Check IP and/or name bans - bans = ServerConfig.objects.conf("server_bans") - if bans and (any(tup[0] == accountname.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." \ - "\nIf you feel this ban is in error, please email an admin.|x" - session.msg(string) - session.sessionhandler.disconnect(session, "Good bye! Disconnecting.") - return + username, password = parts # everything's ok. Create the new account account. - try: - permissions = settings.PERMISSION_ACCOUNT_DEFAULT - typeclass = settings.BASE_CHARACTER_TYPECLASS - new_account = _create_account(session, accountname, password, permissions) - if new_account: - 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: - string += "\n\nYou can now log in with the command 'connect \"%s\" '." - else: - string += "\n\nYou can now log with the command 'connect %s '." - session.msg(string % (accountname, accountname)) - - except Exception: - # We are in the middle between logged in and -not, so we have - # to handle tracebacks ourselves at this point. If we don't, - # we won't see any errors at all. - session.msg("An error occurred. Please e-mail an admin if the problem persists.") - logger.log_trace() + account, errors = Account.create(username=username, password=password, ip=address, session=session) + if account: + # tell the caller everything went well. + string = "A new account '%s' was created. Welcome!" + if " " in username: + string += "\n\nYou can now log in with the command 'connect \"%s\" '." + else: + string += "\n\nYou can now log with the command 'connect %s '." + session.msg(string % (username, username)) + else: + session.msg("|R%s|n" % '\n'.join(errors)) class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS): diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index a7d74ae0e4..90d1b04ad5 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -5,7 +5,7 @@ Base typeclass for in-game Channels. from evennia.typeclasses.models import TypeclassBase from evennia.comms.models import TempMsg, ChannelDB from evennia.comms.managers import ChannelManager -from evennia.utils import logger +from evennia.utils import create, logger from evennia.utils.utils import make_iter from future.utils import with_metaclass _CHANNEL_HANDLER = None @@ -220,6 +220,50 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): return self.locks.check(accessing_obj, access_type=access_type, default=default, no_superuser_bypass=no_superuser_bypass) + @classmethod + def create(cls, key, account=None, *args, **kwargs): + """ + Creates a basic Channel with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_channel() function. + + Args: + key (str): This must be unique. + account (Account): Account to attribute this object to. + + Kwargs: + aliases (list of str): List of alternative (likely shorter) keynames. + description (str): A description of the channel, for use in listings. + locks (str): Lockstring. + keep_log (bool): Log channel throughput. + typeclass (str or class): The typeclass of the Channel (not + often used). + ip (str): IP address of creator (for object auditing). + + Returns: + channel (Channel): A newly created Channel. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + ip = kwargs.pop('ip', '') + + try: + kwargs['desc'] = kwargs.pop('description', '') + obj = create.create_channel(key, *args, **kwargs) + + # Record creator id and creation IP + if ip: obj.db.creator_ip = ip + if account: obj.db.creator_id = account.id + + except Exception as exc: + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(exc) + + return obj, errors + def delete(self): """ Deletes channel while also cleaning up channelhandler. diff --git a/evennia/comms/tests.py b/evennia/comms/tests.py new file mode 100644 index 0000000000..d38e34544b --- /dev/null +++ b/evennia/comms/tests.py @@ -0,0 +1,13 @@ + +from evennia.utils.test_resources import EvenniaTest +from evennia import DefaultChannel + +class ObjectCreationTest(EvenniaTest): + + def test_channel_create(self): + description = "A place to talk about coffee." + + obj, errors = DefaultChannel.create('coffeetalk', description=description) + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertEqual(description, obj.db.desc) \ No newline at end of file diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 919da40a7c..f41dd02d02 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -21,6 +21,7 @@ from evennia.scripts.scripthandler import ScriptHandler from evennia.commands import cmdset, command from evennia.commands.cmdsethandler import CmdSetHandler from evennia.commands import cmdhandler +from evennia.utils import create from evennia.utils import search from evennia.utils import logger from evennia.utils import ansi @@ -191,6 +192,10 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): without `obj.save()` having to be called explicitly. """ + # lockstring of newly created objects, for easy overloading. + # Will be formatted with the appropriate attributes. + lockstring = "control:id({account_id}) or perm(Admin);delete:id({account_id}) or perm(Admin)" + objects = ObjectManager() # on-object properties @@ -425,7 +430,8 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): # only allow exact matching if searching the entire database # or unique #dbrefs exact = True - elif candidates is None: + else: + # TODO: write code...if candidates is None: # no custom candidates given - get them automatically if location: # location(s) were given @@ -858,6 +864,67 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): obj.msg(_(string)) obj.move_to(home) + @classmethod + def create(cls, key, account=None, **kwargs): + """ + Creates a basic object with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_object() function. + + Args: + key (str): Name of the new object. + account (Account): Account to attribute this object to. + + Kwargs: + description (str): Brief description for this object. + ip (str): IP address of creator (for object auditing). + + Returns: + object (Object): A newly created object of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + # Get IP address of creator, if available + ip = kwargs.pop('ip', '') + + # If no typeclass supplied, use this class + kwargs['typeclass'] = kwargs.pop('typeclass', cls) + + # Set the supplied key as the name of the intended object + kwargs['key'] = key + + # Get a supplied description, if any + description = kwargs.pop('description', '') + + # Create a sane lockstring if one wasn't supplied + lockstring = kwargs.get('locks') + if account and not lockstring: + lockstring = cls.lockstring.format(account_id=account.id) + kwargs['locks'] = lockstring + + # Create object + try: + obj = create.create_object(**kwargs) + + # Record creator id and creation IP + if ip: obj.db.creator_ip = ip + if account: obj.db.creator_id = account.id + + # Set description if there is none, or update it if provided + if description or not obj.db.desc: + desc = description if description else "You see nothing special." + obj.db.desc = desc + + except Exception as e: + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(e) + + return obj, errors + def copy(self, new_key=None): """ Makes an identical copy of this object, identical except for a @@ -1825,6 +1892,84 @@ class DefaultCharacter(DefaultObject): a character avatar controlled by an account. """ + # lockstring of newly created rooms, for easy overloading. + # Will be formatted with the appropriate attributes. + lockstring = "puppet:id({character_id}) or pid({account_id}) or perm(Developer) or pperm(Developer)" + + @classmethod + def create(cls, key, account, **kwargs): + """ + Creates a basic Character with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_character() function. + + Args: + key (str): Name of the new Character. + account (obj): Account to associate this Character with. Required as + an argument, but one can fake it out by supplying None-- it will + change the default lockset and skip creator attribution. + + Kwargs: + description (str): Brief description for this object. + ip (str): IP address of creator (for object auditing). + + Returns: + character (Object): A newly created Character of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + # Get IP address of creator, if available + ip = kwargs.pop('ip', '') + + # If no typeclass supplied, use this class + kwargs['typeclass'] = kwargs.pop('typeclass', cls) + + # Set the supplied key as the name of the intended object + kwargs['key'] = key + + # Get home for character + kwargs['home'] = ObjectDB.objects.get_id(kwargs.get('home', settings.DEFAULT_HOME)) + + # Get permissions + kwargs['permissions'] = kwargs.get('permissions', settings.PERMISSION_ACCOUNT_DEFAULT) + + # Get description if provided + description = kwargs.pop('description', '') + + # Get locks if provided + locks = kwargs.pop('locks', '') + + try: + # Create the Character + obj = create.create_object(**kwargs) + + # Record creator id and creation IP + if ip: obj.db.creator_ip = ip + if account: obj.db.creator_id = account.id + + # Add locks + if not locks and account: + # Allow only the character itself and the creator account to puppet this character (and Developers). + locks = cls.lockstring.format(**{'character_id': obj.id, 'account_id': account.id}) + elif not locks and not account: + locks = cls.lockstring.format(**{'character_id': obj.id, 'account_id': -1}) + + obj.locks.add(locks) + + # If no description is set, set a default description + if description or not obj.db.desc: + obj.db.desc = description if description else "This is a character." + + except Exception as e: + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(e) + + return obj, errors + def basetype_setup(self): """ Setup character-specific security. @@ -1941,6 +2086,73 @@ class DefaultRoom(DefaultObject): This is the base room object. It's just like any Object except its location is always `None`. """ + # lockstring of newly created rooms, for easy overloading. + # Will be formatted with the {id} of the creating object. + lockstring = "control:id({id}) or perm(Admin); " \ + "delete:id({id}) or perm(Admin); " \ + "edit:id({id}) or perm(Admin)" + + @classmethod + def create(cls, key, account, **kwargs): + """ + Creates a basic Room with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_object() function. + + Args: + key (str): Name of the new Room. + account (obj): Account to associate this Room with. + + Kwargs: + description (str): Brief description for this object. + ip (str): IP address of creator (for object auditing). + + Returns: + room (Object): A newly created Room of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + # Get IP address of creator, if available + ip = kwargs.pop('ip', '') + + # If no typeclass supplied, use this class + kwargs['typeclass'] = kwargs.pop('typeclass', cls) + + # Set the supplied key as the name of the intended object + kwargs['key'] = key + + # Get who to send errors to + kwargs['report_to'] = kwargs.pop('report_to', account) + + # Get description, if provided + description = kwargs.pop('description', '') + + try: + # Create the Room + obj = create.create_object(**kwargs) + + # Set appropriate locks + lockstring = kwargs.get('locks', cls.lockstring.format(id=account.id)) + obj.locks.add(lockstring) + + # Record creator id and creation IP + if ip: obj.db.creator_ip = ip + if account: obj.db.creator_id = account.id + + # If no description is set, set a default description + if description or not obj.db.desc: + obj.db.desc = description if description else "This is a room." + + except Exception as e: + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(e) + + return obj, errors + def basetype_setup(self): """ Simple room setup setting locks to make sure the room @@ -2018,6 +2230,13 @@ class DefaultExit(DefaultObject): exit_command = ExitCommand priority = 101 + + # lockstring of newly created exits, for easy overloading. + # Will be formatted with the {id} of the creating object. + lockstring = "control:id({id}) or perm(Admin); " \ + "delete:id({id}) or perm(Admin); " \ + "edit:id({id}) or perm(Admin)" + # Helper classes and methods to implement the Exit. These need not # be overloaded unless one want to change the foundation for how # Exits work. See the end of the class for hook methods to overload. @@ -2056,6 +2275,73 @@ class DefaultExit(DefaultObject): # Command hooks + @classmethod + def create(cls, key, account, source, dest, **kwargs): + """ + Creates a basic Exit with default parameters, unless otherwise + specified or extended. + + Provides a friendlier interface to the utils.create_object() function. + + Args: + key (str): Name of the new Exit, as it should appear from the + source room. + account (obj): Account to associate this Exit with. + source (Room): The room to create this exit in. + dest (Room): The room to which this exit should go. + + Kwargs: + description (str): Brief description for this object. + ip (str): IP address of creator (for object auditing). + + Returns: + exit (Object): A newly created Room of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + # Get IP address of creator, if available + ip = kwargs.pop('ip', '') + + # If no typeclass supplied, use this class + kwargs['typeclass'] = kwargs.pop('typeclass', cls) + + # Set the supplied key as the name of the intended object + kwargs['key'] = key + + # Get who to send errors to + kwargs['report_to'] = kwargs.pop('report_to', account) + + # Set to/from rooms + kwargs['location'] = source + kwargs['destination'] = dest + + description = kwargs.pop('description', '') + + try: + # Create the Exit + obj = create.create_object(**kwargs) + + # Set appropriate locks + lockstring = kwargs.get('locks', cls.lockstring.format(id=account.id)) + obj.locks.add(lockstring) + + # Record creator id and creation IP + if ip: obj.db.creator_ip = ip + if account: obj.db.creator_id = account.id + + # If no description is set, set a default description + if description or not obj.db.desc: + obj.db.desc = description if description else "This is an exit." + + except Exception as e: + errors.append("An error occurred while creating this '%s' object." % key) + logger.log_err(e) + + return obj, errors + def basetype_setup(self): """ Setup exit-security diff --git a/evennia/objects/tests.py b/evennia/objects/tests.py index 2c014cbe77..6bfe34248f 100644 --- a/evennia/objects/tests.py +++ b/evennia/objects/tests.py @@ -1,8 +1,43 @@ from evennia.utils.test_resources import EvenniaTest +from evennia import DefaultObject, DefaultCharacter, DefaultRoom, DefaultExit class DefaultObjectTest(EvenniaTest): + ip = '212.216.139.14' + + def test_object_create(self): + description = 'A home for a grouch.' + obj, errors = DefaultObject.create('trashcan', self.account, description=description, ip=self.ip) + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertEqual(description, obj.db.desc) + self.assertEqual(obj.db.creator_ip, self.ip) + + def test_character_create(self): + description = 'A furry green monster, reeking of garbage.' + obj, errors = DefaultCharacter.create('oscar', self.account, description=description, ip=self.ip) + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertEqual(description, obj.db.desc) + self.assertEqual(obj.db.creator_ip, self.ip) + + def test_room_create(self): + description = 'A dimly-lit alley behind the local Chinese restaurant.' + obj, errors = DefaultRoom.create('alley', self.account, description=description, ip=self.ip) + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertEqual(description, obj.db.desc) + self.assertEqual(obj.db.creator_ip, self.ip) + + def test_exit_create(self): + description = 'The steaming depths of the dumpster, ripe with refuse in various states of decomposition.' + obj, errors = DefaultExit.create('in', self.account, self.room1, self.room2, description=description, ip=self.ip) + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + self.assertEqual(description, obj.db.desc) + self.assertEqual(obj.db.creator_ip, self.ip) + def test_urls(self): "Make sure objects are returning URLs" self.assertTrue(self.char1.get_absolute_url()) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 8bff161cf5..e611ede0e2 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -12,7 +12,7 @@ from django.utils.translation import ugettext as _ from evennia.typeclasses.models import TypeclassBase from evennia.scripts.models import ScriptDB from evennia.scripts.manager import ScriptManager -from evennia.utils import logger +from evennia.utils import create, logger from future.utils import with_metaclass __all__ = ["DefaultScript", "DoNothing", "Store"] @@ -324,6 +324,32 @@ class DefaultScript(ScriptBase): """ + @classmethod + def create(cls, key, **kwargs): + """ + Provides a passthrough interface to the utils.create_script() function. + + Args: + key (str): Name of the new object. + + Returns: + object (Object): A newly created object of the given typeclass. + errors (list): A list of errors in string form, if any. + + """ + errors = [] + obj = None + + kwargs['key'] = key + + try: + obj = create.create_script(**kwargs) + except Exception as e: + errors.append("The script '%s' encountered errors and could not be created." % key) + logger.log_err(e) + + return obj, errors + def at_script_creation(self): """ Only called once, when script is first created. diff --git a/evennia/scripts/tests.py b/evennia/scripts/tests.py index dbd36ae956..f5120d0da5 100644 --- a/evennia/scripts/tests.py +++ b/evennia/scripts/tests.py @@ -1,10 +1,20 @@ # this is an optimized version only available in later Django versions from unittest import TestCase +from evennia import DefaultScript from evennia.scripts.models import ScriptDB, ObjectDoesNotExist from evennia.utils.create import create_script +from evennia.utils.test_resources import EvenniaTest from evennia.scripts.scripts import DoNothing +class TestScript(EvenniaTest): + + def test_create(self): + "Check the script can be created via the convenience method." + obj, errors = DefaultScript.create('useless-machine') + self.assertTrue(obj, errors) + self.assertFalse(errors, errors) + class TestScriptDB(TestCase): "Check the singleton/static ScriptDB object works correctly" diff --git a/evennia/server/throttle.py b/evennia/server/throttle.py index 56c88c63f2..a1e6092844 100644 --- a/evennia/server/throttle.py +++ b/evennia/server/throttle.py @@ -1,25 +1,27 @@ from collections import defaultdict, deque +from evennia.utils import logger import time + class Throttle(object): """ - Keeps a running count of failed actions per IP address. - + 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 @@ -31,55 +33,67 @@ class Throttle(object): 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 + 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 (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): + + def update(self, ip, failmsg='Exceeded threshold.'): """ - Store the time of the latest failure/ - + Store the time of the latest failure. + Args: ip (str): IP address of requestor - + failmsg (str, optional): Message to display in logs upon activation + of throttle. + Returns: None - + """ + # Get current status + previously_throttled = self.check(ip) + # Enforce length limits if not self.storage[ip].maxlen: self.storage[ip] = deque(maxlen=self.cache_size) - + self.storage[ip].append(time.time()) - + + # See if this update caused a change in status + currently_throttled = self.check(ip) + + # If this makes it engage, log a single activation event + if (not previously_throttled and currently_throttled): + logger.log_sec('Throttle Activated: %s (IP: %s, %i hits in %i seconds.)' % (failmsg, ip, self.limit, self.timeout)) + def check(self, ip): """ This will check the session's address against the - storage dictionary to check they haven't spammed too many + 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) @@ -97,5 +111,3 @@ class Throttle(object): return False else: return False - - \ No newline at end of file diff --git a/evennia/server/validators.py b/evennia/server/validators.py index b10f990a8a..faa1aa68c8 100644 --- a/evennia/server/validators.py +++ b/evennia/server/validators.py @@ -1,34 +1,73 @@ +from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ +from evennia.accounts.models import AccountDB import re + +class EvenniaUsernameAvailabilityValidator: + """ + Checks to make sure a given username is not taken or otherwise reserved. + """ + + def __call__(self, username): + """ + Validates a username to make sure it is not in use or reserved. + + Args: + username (str): Username to validate + + Returns: + None (None): None if password successfully validated, + raises ValidationError otherwise. + + """ + + # Check guest list + if (settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.GUEST_LIST)): + raise ValidationError( + _('Sorry, that username is reserved.'), + code='evennia_username_reserved', + ) + + # Check database + exists = AccountDB.objects.filter(username__iexact=username).exists() + if exists: + raise ValidationError( + _('Sorry, that username is already taken.'), + code='evennia_username_taken', + ) + + class EvenniaPasswordValidator: - - def __init__(self, regex=r"^[\w. @+\-',]+$", policy="Password should contain a mix of letters, spaces, digits and @/./+/-/_/'/, only."): + + def __init__(self, regex=r"^[\w. @+\-',]+$", + policy="Password should contain a mix of letters, " + "spaces, digits and @/./+/-/_/'/, only."): """ Constructs a standard Django password validator. - + Args: regex (str): Regex pattern of valid characters to allow. policy (str): Brief explanation of what the defined regex permits. - + """ self.regex = regex self.policy = policy - + def validate(self, password, user=None): """ Validates a password string to make sure it meets predefined Evennia acceptable character policy. - + Args: password (str): Password to validate user (None): Unused argument but required by Django - + Returns: None (None): None if password successfully validated, raises ValidationError otherwise. - + """ # Check complexity if not re.findall(self.regex, password): @@ -41,11 +80,12 @@ class EvenniaPasswordValidator: """ Returns a user-facing explanation of the password policy defined by this validator. - + Returns: text (str): Explanation of password policy. - + """ return _( - "%s From a terminal client, you can also use a phrase of multiple words if you enclose the password in double quotes." % self.policy - ) \ No newline at end of file + "%s From a terminal client, you can also use a phrase of multiple words if " + "you enclose the password in double quotes." % self.policy + ) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 9d6ad9e686..40c36e6f7e 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -222,7 +222,8 @@ COMMAND_RATE_WARNING = "You entered commands too fast. Wait a moment and try aga # 0 or less. MAX_CHAR_LIMIT = 6000 # The warning to echo back to users if they enter a very large string -MAX_CHAR_LIMIT_WARNING = "You entered a string that was too long. Please break it up into multiple parts." +MAX_CHAR_LIMIT_WARNING = ("You entered a string that was too long. " + "Please break it up into multiple parts.") # If this is true, errors and tracebacks from the engine will be # echoed as text in-game as well as to the log. This can speed up # debugging. OBS: Showing full tracebacks to regular users could be a @@ -410,12 +411,14 @@ CMDSET_CHARACTER = "commands.default_cmdsets.CharacterCmdSet" CMDSET_ACCOUNT = "commands.default_cmdsets.AccountCmdSet" # Location to search for cmdsets if full path not given CMDSET_PATHS = ["commands", "evennia", "contribs"] -# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your default cmdsets, -# you will also need to copy CMDSET_FALLBACKS after your change in your settings file for it to detect the change. -CMDSET_FALLBACKS = {CMDSET_CHARACTER: 'evennia.commands.default.cmdset_character.CharacterCmdSet', - CMDSET_ACCOUNT: 'evennia.commands.default.cmdset_account.AccountCmdSet', - CMDSET_SESSION: 'evennia.commands.default.cmdset_session.SessionCmdSet', - CMDSET_UNLOGGEDIN: 'evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet'} +# Fallbacks for cmdset paths that fail to load. Note that if you change the path for your +# default cmdsets, you will also need to copy CMDSET_FALLBACKS after your change in your +# settings file for it to detect the change. +CMDSET_FALLBACKS = { + CMDSET_CHARACTER: 'evennia.commands.default.cmdset_character.CharacterCmdSet', + CMDSET_ACCOUNT: 'evennia.commands.default.cmdset_account.AccountCmdSet', + CMDSET_SESSION: 'evennia.commands.default.cmdset_session.SessionCmdSet', + CMDSET_UNLOGGEDIN: 'evennia.commands.default.cmdset_unloggedin.UnloggedinCmdSet'} # Parent class for all default commands. Changing this class will # modify all default commands, so do so carefully. COMMAND_DEFAULT_CLASS = "evennia.commands.default.muxcommand.MuxCommand" @@ -811,6 +814,15 @@ AUTH_PASSWORD_VALIDATORS = [ {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, {'NAME': 'evennia.server.validators.EvenniaPasswordValidator'}] +# Username validation plugins +AUTH_USERNAME_VALIDATORS = [ + {'NAME': 'django.contrib.auth.validators.ASCIIUsernameValidator'}, + {'NAME': 'django.core.validators.MinLengthValidator', + 'OPTIONS': {'limit_value': 3}}, + {'NAME': 'django.core.validators.MaxLengthValidator', + 'OPTIONS': {'limit_value': 30}}, + {'NAME': 'evennia.server.validators.EvenniaUsernameAvailabilityValidator'}] + # Use a custom test runner that just tests Evennia-specific apps. TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner' @@ -821,7 +833,7 @@ TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner' # Django extesions are useful third-party tools that are not # always included in the default django distro. try: - import django_extensions + import django_extensions # noqa INSTALLED_APPS = INSTALLED_APPS + ('django_extensions',) except ImportError: # Django extensions are not installed in all distros.