Moves account creation logic from Commands module to Account class.

This commit is contained in:
Johnny 2018-10-02 20:23:23 +00:00
parent 16648d47d1
commit 21d66ab625
4 changed files with 354 additions and 176 deletions

View file

@ -23,8 +23,9 @@ 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.server.models import ServerConfig
from evennia.server.throttle import Throttle
from evennia.utils import logger
from evennia.utils import create, logger
from evennia.utils.utils import (lazy_property, to_str,
make_iter, to_unicode, is_iter,
variable_from_module)
@ -34,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",)
@ -364,6 +366,31 @@ 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', [])):
"""
@ -386,9 +413,85 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
raise ImproperlyConfigured(msg % validator['NAME'])
objs.append(klass(**validator.get('OPTIONS', {})))
return objs
@classmethod
def authenticate_guest(cls, **kwargs):
"""
Gets or creates a Guest account object.
Kwargs:
ip (str, optional): IP address of requestor; used for ban checking,
throttling and logging
"""
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
# 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 if IP banned
if ip and cls.is_banned(ip=ip):
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).' % ('guest', ip))
LOGIN_THROTTLE.update(ip, 'Too many sightings of banned IP.')
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 = ObjectDB.objects.get_id(settings.GUEST_HOME)
permissions = settings.PERMISSION_GUEST_DEFAULT
character_typeclass = settings.BASE_CHARACTER_TYPECLASS
account_typeclass = settings.BASE_GUEST_TYPECLASS
account, errs = cls.create(
guest=True,
username=username,
password=password,
permissions=permissions,
account_typeclass=account_typeclass,
character_typeclass=character_typeclass,
ip=ip,
)
errors.extend(errs)
return account, errors
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()
return None, errors
return account, errors
@classmethod
def authenticate(cls, username, password, ip=None):
def authenticate(cls, username, password, ip='', **kwargs):
"""
Checks the given username/password against the database to see if the
credentials are valid.
@ -408,6 +511,9 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
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.
@ -423,7 +529,17 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
# 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 fill up your disk.
# 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
@ -436,7 +552,14 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip))
# Update throttle
if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures')
if ip: LOGIN_THROTTLE.update(ip, 'Too many authentication failures.')
# Try to call post-failure hook
session = kwargs.get('session', None)
if session:
account = AccountDB.objects.get_account_from_name(username)
if account:
account.at_failed_login(session)
return None, errors
@ -493,7 +616,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
valid.append(not validator(username))
except ValidationError as e:
valid.append(False)
[errors.append(x) for x in e.messages]
errors.extend(e.messages)
# Disqualify if any check failed
if False in valid:
@ -561,6 +684,142 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
super(DefaultAccount, self).set_password(password)
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 MM<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
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)
account_typeclass = kwargs.get('account_typeclass', settings.BASE_ACCOUNT_TYPECLASS)
character_typeclass = kwargs.get('character_typeclass', settings.BASE_CHARACTER_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=account_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:
if settings.MULTISESSION_MODE < 2:
default_home = ObjectDB.objects.get_id(settings.DEFAULT_HOME)
try:
character = create.create_object(character_typeclass, key=account.key, home=default_home, permissions=permissions)
# set playable character list
account.db._playable_characters.append(character)
# allow only the character itself and the account to puppet this character (and Developers).
character.locks.add("puppet:id(%i) or pid(%i) or perm(Developer) or pperm(Developer)" %
(character.id, account.id))
# If no description is set, set a default description
if not character.db.desc:
character.db.desc = "This is a character."
# We need to set this to have @ic auto-connect to this character
account.db._last_puppet = character
# Record creator id and creation IP
if ip: character.db.creator_ip = ip
character.db.creator_id = account.id
except Exception as e:
errors.append("There was an error creating a Character. If this problem persists, contact an admin.")
logger.log_trace()
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):
"""

View file

@ -75,6 +75,41 @@ class TestDefaultAccount(TestCase):
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
account, errors = DefaultAccount.create(username='Ziggy', password='starman11')
self.assertFalse(account, 'Duplicate account name should not have been allowed.')
# Guest account should not be permitted
account, errors = DefaultAccount.authenticate_guest()
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 = DefaultAccount.authenticate_guest()
self.assertTrue(account, 'Guest account should have been created.')
# Create a second guest account
account, errors = DefaultAccount.authenticate_guest()
self.assertFalse(account, 'Two guest accounts were created despite a single entry on the guest list!')
settings.GUEST_ENABLED = False
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
@ -92,7 +127,7 @@ class TestDefaultAccount(TestCase):
def test_password_validation(self):
"Check password validators deny bad passwords"
self.account = create.create_account("TestAccount%s" % randint(0, 9),
self.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(self.account.validate_password(bad, account=self.account)[0])

View file

@ -4,10 +4,9 @@ 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.accounts import CREATION_THROTTLE, LOGIN_THROTTLE
from evennia.accounts.models import AccountDB
from evennia.objects.models import ObjectDB
from evennia.comms.models import ChannelDB
@ -15,7 +14,7 @@ from evennia.server.models import ServerConfig
from evennia.server.sessionhandler import SESSIONS
from evennia.server.throttle import Throttle
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)
@ -27,9 +26,6 @@ __all__ = ("CmdUnconnectedConnect", "CmdUnconnectedCreate",
MULTISESSION_MODE = settings.MULTISESSION_MODE
CONNECTION_SCREEN_MODULE = settings.CONNECTION_SCREEN_MODULE
# Create throttles for too many connections
CONNECTION_THROTTLE = Throttle(limit=5, timeout=1 * 60)
def create_guest_account(session):
"""
Creates a guest account/character for this session, if one is available.
@ -42,50 +38,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
# 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
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
enabled = settings.GUEST_ENABLED
address = session.address
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
# Get an available guest account
# authenticate_guest() handles its own throttling
account, errors = Account.authenticate_guest(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):
"""
@ -99,38 +65,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.
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
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
# 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
@ -162,15 +107,10 @@ 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
# Get account class
Account = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
args = self.args
# extract double quote parts
@ -178,23 +118,27 @@ 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:
account, errors = Account.authenticate_guest(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 <name> <password>")
return
CONNECTION_THROTTLE.update(address)
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):
@ -220,14 +164,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()]
@ -239,77 +179,21 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
"\nIf <name> or <password> 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\" <your password>'."
else:
string += "\n\nYou can now log with the command 'connect %s <your password>'."
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\" <your password>'."
else:
string += "\n\nYou can now log with the command 'connect %s <your password>'."
session.msg(string % (username, username))
else:
session.msg("|R%s|n" % '\n'.join(errors))
class CmdUnconnectedQuit(COMMAND_DEFAULT_CLASS):

View file

@ -23,7 +23,7 @@ class EvenniaUsernameAvailabilityValidator:
"""
# Check guest list
if settings.GUEST_LIST and username.lower() in (guest.lower() for guest in settings.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',