mirror of
https://github.com/evennia/evennia.git
synced 2026-03-16 21:06:30 +01:00
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
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import password_validation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
from evennia.typeclasses.models import TypeclassBase
|
||||
from evennia.accounts.manager import AccountManager
|
||||
|
|
@ -357,7 +359,66 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
|||
puppet = property(__get_single_puppet)
|
||||
|
||||
# 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):
|
||||
"""
|
||||
Deletes the account permanently.
|
||||
|
|
@ -714,6 +775,17 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
|
|||
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -57,6 +57,29 @@ class TestDefaultAccount(TestCase):
|
|||
def setUp(self):
|
||||
self.s1 = Session()
|
||||
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):
|
||||
"Check puppet_object method called with no object param"
|
||||
|
|
@ -157,4 +180,4 @@ class TestDefaultAccount(TestCase):
|
|||
|
||||
account.puppet_object(self.s1, obj)
|
||||
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
|
||||
oldpass = self.lhslist[0] # Both of these are
|
||||
newpass = self.rhslist[0] # already stripped by parse()
|
||||
|
||||
# Validate password
|
||||
validated, error = account.validate_password(newpass)
|
||||
|
||||
if not account.check_password(oldpass):
|
||||
self.msg("The specified old password isn't correct.")
|
||||
elif len(newpass) < 3:
|
||||
self.msg("Passwords must be at least three characters long.")
|
||||
elif not validated:
|
||||
errors = [e for suberror in error.messages for e in error.messages]
|
||||
string = "\n".join(errors)
|
||||
self.msg(string)
|
||||
else:
|
||||
account.set_password(newpass)
|
||||
account.save()
|
||||
|
|
|
|||
|
|
@ -428,12 +428,23 @@ class CmdNewPassword(COMMAND_DEFAULT_CLASS):
|
|||
account = caller.search_account(self.lhs)
|
||||
if not account:
|
||||
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()
|
||||
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:
|
||||
account.msg("%s has changed your password to '%s'." % (caller.name,
|
||||
self.rhs))
|
||||
newpass))
|
||||
|
||||
|
||||
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."
|
||||
session.msg(string)
|
||||
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." \
|
||||
"\nFor best security, make it longer than 8 characters. You can also use a phrase of" \
|
||||
"\nmany words if you enclose the password in double quotes."
|
||||
|
||||
# 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
|
||||
|
||||
|
|
|
|||
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
|
||||
except ImportError:
|
||||
import unittest
|
||||
|
||||
from evennia.server.validators import EvenniaPasswordValidator
|
||||
from evennia.utils.test_resources import EvenniaTest
|
||||
|
||||
from django.test.runner import DiscoverRunner
|
||||
|
||||
|
|
@ -77,3 +80,16 @@ class TestDeprecations(TestCase):
|
|||
self.assertRaises(DeprecationWarning, check_errors, MockSettings(setting))
|
||||
# test check for WEBSERVER_PORTS having correct value
|
||||
self.assertRaises(DeprecationWarning, check_errors, MockSettings("WEBSERVER_PORTS", value=["not a tuple"]))
|
||||
|
||||
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.
|
||||
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.
|
||||
TEST_RUNNER = 'evennia.server.tests.EvenniaTestSuiteRunner'
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue