Merge branch 'validation' of https://github.com/strikaco/evennia-dev into strikaco-validation
This commit is contained in:
commit
33f04312f2
9 changed files with 217 additions and 11 deletions
|
|
@ -13,6 +13,8 @@ instead for most things).
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import password_validation
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from evennia.typeclasses.models import TypeclassBase
|
from evennia.typeclasses.models import TypeclassBase
|
||||||
from evennia.accounts.manager import AccountManager
|
from evennia.accounts.manager import AccountManager
|
||||||
|
|
@ -357,7 +359,66 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
||||||
puppet = property(__get_single_puppet)
|
puppet = property(__get_single_puppet)
|
||||||
|
|
||||||
# utility methods
|
# utility methods
|
||||||
|
@classmethod
|
||||||
|
def validate_password(cls, password, account=None):
|
||||||
|
"""
|
||||||
|
Checks the given password against the list of Django validators enabled
|
||||||
|
in the server.conf file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password (str): Password to validate
|
||||||
|
|
||||||
|
Kwargs:
|
||||||
|
account (DefaultAccount, optional): Account object to validate the
|
||||||
|
password for. Optional, but Django includes some validators to
|
||||||
|
do things like making sure users aren't setting passwords to the
|
||||||
|
same value as their username. If left blank, these user-specific
|
||||||
|
checks are skipped.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
valid (bool): Whether or not the password passed validation
|
||||||
|
error (ValidationError, None): Any validation error(s) raised. Multiple
|
||||||
|
errors can be nested within a single object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
valid = False
|
||||||
|
error = None
|
||||||
|
|
||||||
|
# Validation returns None on success; invert it and return a more sensible bool
|
||||||
|
try:
|
||||||
|
valid = not password_validation.validate_password(password, user=account)
|
||||||
|
except ValidationError as e:
|
||||||
|
error = e
|
||||||
|
|
||||||
|
return valid, error
|
||||||
|
|
||||||
|
def set_password(self, password, force=False):
|
||||||
|
"""
|
||||||
|
Applies the given password to the account if it passes validation checks.
|
||||||
|
Can be overridden by using the 'force' flag.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password (str): Password to set
|
||||||
|
|
||||||
|
Kwargs:
|
||||||
|
force (bool): Sets password without running validation checks.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None (None): Does not return a value.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not force:
|
||||||
|
# Run validation checks
|
||||||
|
valid, error = self.validate_password(password, account=self)
|
||||||
|
if error: raise error
|
||||||
|
|
||||||
|
super(DefaultAccount, self).set_password(password)
|
||||||
|
logger.log_info("Password succesfully changed for %s." % self)
|
||||||
|
self.at_password_change()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
Deletes the account permanently.
|
Deletes the account permanently.
|
||||||
|
|
@ -714,6 +775,17 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def at_password_change(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Called after a successful password set/modify.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs (dict): Arbitrary, optional arguments for users
|
||||||
|
overriding the call (unused by default).
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
def at_pre_login(self, **kwargs):
|
def at_pre_login(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,29 @@ class TestDefaultAccount(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.s1 = Session()
|
self.s1 = Session()
|
||||||
self.s1.sessid = 0
|
self.s1.sessid = 0
|
||||||
|
|
||||||
|
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):
|
def test_puppet_object_no_object(self):
|
||||||
"Check puppet_object method called with no object param"
|
"Check puppet_object method called with no object param"
|
||||||
|
|
@ -157,4 +180,4 @@ class TestDefaultAccount(TestCase):
|
||||||
|
|
||||||
account.puppet_object(self.s1, obj)
|
account.puppet_object(self.s1, obj)
|
||||||
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account."))
|
self.assertTrue(self.s1.data_out.call_args[1]['text'].endswith("is already puppeted by another Account."))
|
||||||
self.assertIsNone(obj.at_post_puppet.call_args)
|
self.assertIsNone(obj.at_post_puppet.call_args)
|
||||||
|
|
@ -627,10 +627,16 @@ class CmdPassword(COMMAND_DEFAULT_CLASS):
|
||||||
return
|
return
|
||||||
oldpass = self.lhslist[0] # Both of these are
|
oldpass = self.lhslist[0] # Both of these are
|
||||||
newpass = self.rhslist[0] # already stripped by parse()
|
newpass = self.rhslist[0] # already stripped by parse()
|
||||||
|
|
||||||
|
# Validate password
|
||||||
|
validated, error = account.validate_password(newpass)
|
||||||
|
|
||||||
if not account.check_password(oldpass):
|
if not account.check_password(oldpass):
|
||||||
self.msg("The specified old password isn't correct.")
|
self.msg("The specified old password isn't correct.")
|
||||||
elif len(newpass) < 3:
|
elif not validated:
|
||||||
self.msg("Passwords must be at least three characters long.")
|
errors = [e for suberror in error.messages for e in error.messages]
|
||||||
|
string = "\n".join(errors)
|
||||||
|
self.msg(string)
|
||||||
else:
|
else:
|
||||||
account.set_password(newpass)
|
account.set_password(newpass)
|
||||||
account.save()
|
account.save()
|
||||||
|
|
|
||||||
|
|
@ -428,12 +428,23 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS):
|
||||||
account = caller.search_account(self.lhs)
|
account = caller.search_account(self.lhs)
|
||||||
if not account:
|
if not account:
|
||||||
return
|
return
|
||||||
account.set_password(self.rhs)
|
|
||||||
|
newpass = self.rhs
|
||||||
|
|
||||||
|
# Validate password
|
||||||
|
validated, error = account.validate_password(newpass)
|
||||||
|
if not validated:
|
||||||
|
errors = [e for suberror in error.messages for e in error.messages]
|
||||||
|
string = "\n".join(errors)
|
||||||
|
caller.msg(string)
|
||||||
|
return
|
||||||
|
|
||||||
|
account.set_password(newpass)
|
||||||
account.save()
|
account.save()
|
||||||
self.msg("%s - new password set to '%s'." % (account.name, self.rhs))
|
self.msg("%s - new password set to '%s'." % (account.name, newpass))
|
||||||
if account.character != caller:
|
if account.character != caller:
|
||||||
account.msg("%s has changed your password to '%s'." % (caller.name,
|
account.msg("%s has changed your password to '%s'." % (caller.name,
|
||||||
self.rhs))
|
newpass))
|
||||||
|
|
||||||
|
|
||||||
class CmdPerm(COMMAND_DEFAULT_CLASS):
|
class CmdPerm(COMMAND_DEFAULT_CLASS):
|
||||||
|
|
|
||||||
|
|
@ -294,10 +294,14 @@ class CmdUnconnectedCreate(COMMAND_DEFAULT_CLASS):
|
||||||
string = "\n\r That name is reserved. Please choose another Accountname."
|
string = "\n\r That name is reserved. Please choose another Accountname."
|
||||||
session.msg(string)
|
session.msg(string)
|
||||||
return
|
return
|
||||||
if not re.findall(r"^[\w. @+\-']+$", password) or not (3 < len(password)):
|
|
||||||
string = "\n\r Password should be longer than 3 characters. Letters, spaces, digits and @/./+/-/_/' only." \
|
# Validate password
|
||||||
"\nFor best security, make it longer than 8 characters. You can also use a phrase of" \
|
Account = utils.class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
|
||||||
"\nmany words if you enclose the password in double quotes."
|
# 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)
|
session.msg(string)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
0
evennia/contrib/security/__init__.py
Normal file
0
evennia/contrib/security/__init__.py
Normal file
|
|
@ -23,6 +23,9 @@ try:
|
||||||
from django.utils import unittest
|
from django.utils import unittest
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from evennia.server.validators import EvenniaPasswordValidator
|
||||||
|
from evennia.utils.test_resources import EvenniaTest
|
||||||
|
|
||||||
from django.test.runner import DiscoverRunner
|
from django.test.runner import DiscoverRunner
|
||||||
|
|
||||||
|
|
@ -77,3 +80,16 @@ class TestDeprecations(TestCase):
|
||||||
self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting))
|
self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting))
|
||||||
# test check for WEBSERVER_PORTS having correct value
|
# test check for WEBSERVER_PORTS having correct value
|
||||||
self.assertRaises(DeprecationWarning, check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"]))
|
self.assertRaises(DeprecationWarning, check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"]))
|
||||||
|
|
||||||
|
class ValidatorTest(EvenniaTest):
|
||||||
|
|
||||||
|
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)
|
||||||
51
evennia/server/validators.py
Normal file
51
evennia/server/validators.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
import re
|
||||||
|
|
||||||
|
class EvenniaPasswordValidator:
|
||||||
|
|
||||||
|
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):
|
||||||
|
raise ValidationError(
|
||||||
|
_(self.policy),
|
||||||
|
code='evennia_password_policy',
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_help_text(self):
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
@ -802,6 +802,29 @@ INSTALLED_APPS = (
|
||||||
# This should usually not be changed.
|
# This should usually not be changed.
|
||||||
AUTH_USER_MODEL = "accounts.AccountDB"
|
AUTH_USER_MODEL = "accounts.AccountDB"
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
'OPTIONS': {
|
||||||
|
'min_length': 8,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'evennia.server.validators.EvenniaPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
# Use a custom test runner that just tests Evennia-specific apps.
|
# Use a custom test runner that just tests Evennia-specific apps.
|
||||||
TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
|
TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue