From e990176a02f01f4bb6a9fa5d15f98fbbf711eada Mon Sep 17 00:00:00 2001 From: Johnny Date: Mon, 1 Oct 2018 21:24:33 +0000 Subject: [PATCH] Adds username normalization/validation and authentication methods to Account class. --- evennia/accounts/accounts.py | 110 ++++++++++++++++++++++++++++++++++- evennia/accounts/tests.py | 29 +++++++++ evennia/server/validators.py | 35 +++++++++++ evennia/settings_default.py | 22 +++++++ 4 files changed, 194 insertions(+), 2 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 07fb8f318c..8da9a7ea9b 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -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): diff --git a/evennia/accounts/tests.py b/evennia/accounts/tests.py index 2855dd0ca2..f1a9f16c28 100644 --- a/evennia/accounts/tests.py +++ b/evennia/accounts/tests.py @@ -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" diff --git a/evennia/server/validators.py b/evennia/server/validators.py index b10f990a8a..bccbde6b51 100644 --- a/evennia/server/validators.py +++ b/evennia/server/validators.py @@ -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."): diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 9efbb6314b..04c928ac99 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -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'