From c8c9e831eec906eac09a2f7164bb75dcc9d2177a Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 20 Sep 2018 20:37:48 +0000 Subject: [PATCH] Forces validation on Account.set_password() and provides an Account.validate_password() method to validate passwords. --- evennia/accounts/accounts.py | 74 ++++++++++++++++++- evennia/accounts/tests.py | 25 ++++++- .../security => server}/validators.py | 0 evennia/settings_default.py | 2 +- 4 files changed, 98 insertions(+), 3 deletions(-) rename evennia/{contrib/security => server}/validators.py (100%) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index c4c8c37df7..ba2616f003 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -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): """ diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 039a25601f..1eabd1e542 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -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) \ No newline at end of file diff --git a/evennia/contrib/security/validators.py b/evennia/server/validators.py similarity index 100% rename from evennia/contrib/security/validators.py rename to evennia/server/validators.py diff --git a/evennia/settings_default.py b/evennia/settings_default.py index d705d77d18..8c07244636 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -821,7 +821,7 @@ AUTH_PASSWORD_VALIDATORS = [ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, { - 'NAME': 'evennia.contrib.security.validators.EvenniaPasswordValidator', + 'NAME': 'evennia.server.validators.EvenniaPasswordValidator', }, ]