Adds username normalization/validation and authentication methods to Account class.

This commit is contained in:
Johnny 2018-10-01 21:24:33 +00:00
parent f407a90f45
commit e990176a02
4 changed files with 194 additions and 2 deletions

View file

@ -13,9 +13,10 @@ 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
@ -359,6 +360,74 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
puppet = property(__get_single_puppet)
# utility methods
@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=None):
"""
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
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)
# 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.')
# System log message
logger.log_sec('Authentication Failure: %s (IP: %s).' % (username, ip))
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):
"""
@ -379,6 +448,43 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
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.append(x) for x in 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):

View file

@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
from mock import Mock
from random import randint
from unittest import TestCase
@ -59,6 +61,33 @@ class TestDefaultAccount(TestCase):
self.s1 = Session()
self.s1.puppet = None
self.s1.sessid = 0
self.password = "testpassword"
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_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"

View file

@ -1,7 +1,42 @@
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."):

View file

@ -809,6 +809,28 @@ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'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'