diff --git a/passbook/core/exceptions.py b/passbook/core/exceptions.py new file mode 100644 index 000000000..b5f5b5277 --- /dev/null +++ b/passbook/core/exceptions.py @@ -0,0 +1,10 @@ +"""passbook core exceptions""" + +class PasswordPolicyInvalid(Exception): + """Exception raised when a Password Policy fails""" + + messages = [] + + def __init__(self, *messages): + super().__init__() + self.messages = messages diff --git a/passbook/core/models.py b/passbook/core/models.py index 31b0b6874..db6c6816e 100644 --- a/passbook/core/models.py +++ b/passbook/core/models.py @@ -4,6 +4,7 @@ from datetime import timedelta from logging import getLogger from random import SystemRandom from time import sleep +from typing import Tuple, Union from uuid import uuid4 from django.contrib.auth.models import AbstractUser @@ -49,7 +50,8 @@ class User(AbstractUser): password_change_date = models.DateTimeField(auto_now_add=True) def set_password(self, password): - password_changed.send(sender=self, user=self, password=password) + if self.pk: + password_changed.send(sender=self, user=self, password=password) self.password_change_date = now() return super().set_password(password) @@ -69,8 +71,9 @@ class PolicyModel(UUIDModel, CreatedUpdatedModel): policies = models.ManyToManyField('Policy', blank=True) - def passes(self, user: User) -> bool: - """Return true if user passes, otherwise False or raise Exception""" + def passes(self, user: User) -> Union[bool, Tuple[bool, str]]: + """Return False, str if a user fails where str is a + reasons shown to the user. Return True if user succeeds.""" for policy in self.policies.all(): if not policy.passes(user): return False @@ -222,7 +225,7 @@ class Policy(UUIDModel, CreatedUpdatedModel): return self.name return "%s action %s" % (self.name, self.action) - def passes(self, user: User) -> bool: + def passes(self, user: User) -> Union[bool, Tuple[bool, str]]: """Check if user instance passes this policy""" raise NotImplementedError() @@ -267,7 +270,7 @@ class FieldMatcherPolicy(Policy): description = "%s: %s" % (self.name, description) return description - def passes(self, user: User) -> bool: + def passes(self, user: User) -> Union[bool, Tuple[bool, str]]: """Check if user instance passes this role""" if not hasattr(user, self.user_field): raise ValueError("Field does not exist") @@ -302,10 +305,11 @@ class PasswordPolicy(Policy): amount_symbols = models.IntegerField(default=0) length_min = models.IntegerField(default=0) symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ") + error_message = models.TextField() form = 'passbook.core.forms.policies.PasswordPolicyForm' - def passes(self, user: User) -> bool: + def passes(self, user: User) -> Union[bool, Tuple[bool, str]]: # Only check if password is being set if not hasattr(user, '__password__'): return True @@ -320,6 +324,8 @@ class PasswordPolicy(Policy): filter_regex += r'[%s]{%d,}' % (self.symbol_charset, self.amount_symbols) result = bool(re.compile(filter_regex).match(password)) LOGGER.debug("User got %r", result) + if not result: + return result, self.error_message return result class Meta: @@ -378,7 +384,7 @@ class DebugPolicy(Policy): wait = SystemRandom().randrange(self.wait_min, self.wait_max) LOGGER.debug("Policy '%s' waiting for %ds", self.name, wait) sleep(wait) - return self.result + return self.result, 'Debugging' class Meta: diff --git a/passbook/core/policies.py b/passbook/core/policies.py index 2a54df0f4..a675ca22b 100644 --- a/passbook/core/policies.py +++ b/passbook/core/policies.py @@ -42,7 +42,11 @@ class PolicyEngine: @property def result(self): """Get policy-checking result""" + messages = [] for policy_result in self._group.get(): + if isinstance(policy_result, (tuple, list)): + policy_result, policy_message = policy_result + messages.append(policy_message) if policy_result is False: - return False - return True + return False, messages + return True, messages diff --git a/passbook/core/signals.py b/passbook/core/signals.py index e497ce34c..4df1d0716 100644 --- a/passbook/core/signals.py +++ b/passbook/core/signals.py @@ -1,12 +1,27 @@ """passbook core signals""" from django.core.signals import Signal +from django.dispatch import receiver -# from django.db.models.signals import post_save, pre_delete -# from django.dispatch import receiver -# from passbook.core.models import Invitation, User +from passbook.core.exceptions import PasswordPolicyInvalid user_signed_up = Signal(providing_args=['request', 'user']) invitation_created = Signal(providing_args=['request', 'invitation']) invitation_used = Signal(providing_args=['request', 'invitation', 'user']) password_changed = Signal(providing_args=['user', 'password']) + +@receiver(password_changed) +# pylint: disable=unused-argument +def password_policy_checker(sender, password, **kwargs): + """Run password through all password policies which are applied to the user""" + from passbook.core.models import PasswordFactor + from passbook.core.policies import PolicyEngine + setattr(sender, '__password__', password) + _all_factors = PasswordFactor.objects.filter(enabled=True).order_by('order') + for factor in _all_factors: + if factor.passes(sender): + policy_engine = PolicyEngine(factor.password_policies.all().select_subclasses()) + policy_engine.for_user(sender) + passing, messages = policy_engine.result + if not passing: + raise PasswordPolicyInvalid(*messages) diff --git a/passbook/core/templates/partials/form_login.html b/passbook/core/templates/partials/form_login.html index 944dbea8a..7d1707a26 100644 --- a/passbook/core/templates/partials/form_login.html +++ b/passbook/core/templates/partials/form_login.html @@ -3,45 +3,48 @@ {% csrf_token %} {% for field in form %} -