implement password policy checking on signup and password change closes #8
This commit is contained in:
parent
96f7e70f9e
commit
421f51770c
10
passbook/core/exceptions.py
Normal file
10
passbook/core/exceptions.py
Normal file
|
@ -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
|
|
@ -4,6 +4,7 @@ from datetime import timedelta
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from random import SystemRandom
|
from random import SystemRandom
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
from typing import Tuple, Union
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
@ -49,6 +50,7 @@ class User(AbstractUser):
|
||||||
password_change_date = models.DateTimeField(auto_now_add=True)
|
password_change_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
|
if self.pk:
|
||||||
password_changed.send(sender=self, user=self, password=password)
|
password_changed.send(sender=self, user=self, password=password)
|
||||||
self.password_change_date = now()
|
self.password_change_date = now()
|
||||||
return super().set_password(password)
|
return super().set_password(password)
|
||||||
|
@ -69,8 +71,9 @@ class PolicyModel(UUIDModel, CreatedUpdatedModel):
|
||||||
|
|
||||||
policies = models.ManyToManyField('Policy', blank=True)
|
policies = models.ManyToManyField('Policy', blank=True)
|
||||||
|
|
||||||
def passes(self, user: User) -> bool:
|
def passes(self, user: User) -> Union[bool, Tuple[bool, str]]:
|
||||||
"""Return true if user passes, otherwise False or raise Exception"""
|
"""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():
|
for policy in self.policies.all():
|
||||||
if not policy.passes(user):
|
if not policy.passes(user):
|
||||||
return False
|
return False
|
||||||
|
@ -222,7 +225,7 @@ class Policy(UUIDModel, CreatedUpdatedModel):
|
||||||
return self.name
|
return self.name
|
||||||
return "%s action %s" % (self.name, self.action)
|
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"""
|
"""Check if user instance passes this policy"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@ -267,7 +270,7 @@ class FieldMatcherPolicy(Policy):
|
||||||
description = "%s: %s" % (self.name, description)
|
description = "%s: %s" % (self.name, description)
|
||||||
return 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"""
|
"""Check if user instance passes this role"""
|
||||||
if not hasattr(user, self.user_field):
|
if not hasattr(user, self.user_field):
|
||||||
raise ValueError("Field does not exist")
|
raise ValueError("Field does not exist")
|
||||||
|
@ -302,10 +305,11 @@ class PasswordPolicy(Policy):
|
||||||
amount_symbols = models.IntegerField(default=0)
|
amount_symbols = models.IntegerField(default=0)
|
||||||
length_min = models.IntegerField(default=0)
|
length_min = models.IntegerField(default=0)
|
||||||
symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ")
|
symbol_charset = models.TextField(default=r"!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ ")
|
||||||
|
error_message = models.TextField()
|
||||||
|
|
||||||
form = 'passbook.core.forms.policies.PasswordPolicyForm'
|
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
|
# Only check if password is being set
|
||||||
if not hasattr(user, '__password__'):
|
if not hasattr(user, '__password__'):
|
||||||
return True
|
return True
|
||||||
|
@ -320,6 +324,8 @@ class PasswordPolicy(Policy):
|
||||||
filter_regex += r'[%s]{%d,}' % (self.symbol_charset, self.amount_symbols)
|
filter_regex += r'[%s]{%d,}' % (self.symbol_charset, self.amount_symbols)
|
||||||
result = bool(re.compile(filter_regex).match(password))
|
result = bool(re.compile(filter_regex).match(password))
|
||||||
LOGGER.debug("User got %r", result)
|
LOGGER.debug("User got %r", result)
|
||||||
|
if not result:
|
||||||
|
return result, self.error_message
|
||||||
return result
|
return result
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -378,7 +384,7 @@ class DebugPolicy(Policy):
|
||||||
wait = SystemRandom().randrange(self.wait_min, self.wait_max)
|
wait = SystemRandom().randrange(self.wait_min, self.wait_max)
|
||||||
LOGGER.debug("Policy '%s' waiting for %ds", self.name, wait)
|
LOGGER.debug("Policy '%s' waiting for %ds", self.name, wait)
|
||||||
sleep(wait)
|
sleep(wait)
|
||||||
return self.result
|
return self.result, 'Debugging'
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
|
|
|
@ -42,7 +42,11 @@ class PolicyEngine:
|
||||||
@property
|
@property
|
||||||
def result(self):
|
def result(self):
|
||||||
"""Get policy-checking result"""
|
"""Get policy-checking result"""
|
||||||
|
messages = []
|
||||||
for policy_result in self._group.get():
|
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:
|
if policy_result is False:
|
||||||
return False
|
return False, messages
|
||||||
return True
|
return True, messages
|
||||||
|
|
|
@ -1,12 +1,27 @@
|
||||||
"""passbook core signals"""
|
"""passbook core signals"""
|
||||||
|
|
||||||
from django.core.signals import Signal
|
from django.core.signals import Signal
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
# from django.db.models.signals import post_save, pre_delete
|
from passbook.core.exceptions import PasswordPolicyInvalid
|
||||||
# from django.dispatch import receiver
|
|
||||||
# from passbook.core.models import Invitation, User
|
|
||||||
|
|
||||||
user_signed_up = Signal(providing_args=['request', 'user'])
|
user_signed_up = Signal(providing_args=['request', 'user'])
|
||||||
invitation_created = Signal(providing_args=['request', 'invitation'])
|
invitation_created = Signal(providing_args=['request', 'invitation'])
|
||||||
invitation_used = Signal(providing_args=['request', 'invitation', 'user'])
|
invitation_used = Signal(providing_args=['request', 'invitation', 'user'])
|
||||||
password_changed = Signal(providing_args=['user', 'password'])
|
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)
|
||||||
|
|
|
@ -3,19 +3,23 @@
|
||||||
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
<div class="form-group login-pf-settings">
|
<div class="form-group login-pf-settings {% if field.errors %} has-error {% endif %}">
|
||||||
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
{% if field.field.widget|fieldtype == 'RadioSelect' %}
|
||||||
<label class="col-sm-2 control-label" {% if field.field.required %}class="required"{% endif %} for="{{ field.name }}-{{ forloop.counter0 }}">
|
<label class="col-sm-2 control-label" {% if field.field.required %}class="required" {% endif %}
|
||||||
|
for="{{ field.name }}-{{ forloop.counter0 }}">
|
||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</label>
|
</label>
|
||||||
{% for c in field %}
|
{% for c in field %}
|
||||||
<div class="radio col-sm-10">
|
<div class="radio col-sm-10">
|
||||||
<input type="radio" id="{{ field.name }}-{{ forloop.counter0 }}" name="{% if wizard %}{{ wizard.steps.current }}-{% endif %}{{ field.name }}" value="{{ c.data.value }}" {% if c.data.selected %} checked {% endif %}>
|
<input type="radio" id="{{ field.name }}-{{ forloop.counter0 }}"
|
||||||
|
name="{% if wizard %}{{ wizard.steps.current }}-{% endif %}{{ field.name }}" value="{{ c.data.value }}"
|
||||||
|
{% if c.data.selected %} checked {% endif %}>
|
||||||
<label class="col-sm-2 control-label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ c.choice_label }}</label>
|
<label class="col-sm-2 control-label" for="{{ field.name }}-{{ forloop.counter0 }}">{{ c.choice_label }}</label>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif field.field.widget|fieldtype == 'Select' %}
|
{% elif field.field.widget|fieldtype == 'Select' %}
|
||||||
<label class="col-sm-2 control-label" {% if field.field.required %}class="required"{% endif %} for="{{ field.name }}-{{ forloop.counter0 }}">
|
<label class="col-sm-2 control-label" {% if field.field.required %}class="required" {% endif %}
|
||||||
|
for="{{ field.name }}-{{ forloop.counter0 }}">
|
||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</label>
|
</label>
|
||||||
<div class="select col-sm-10">
|
<div class="select col-sm-10">
|
||||||
|
@ -26,7 +30,8 @@
|
||||||
{{ field }} {{ field.label }}
|
{{ field }} {{ field.label }}
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
<label class="col-sm-2 sr-only" {% if field.field.required %}class="required"{% endif %} for="{{ field.name }}-{{ forloop.counter0 }}">
|
<label class="col-sm-2 sr-only" {% if field.field.required %}class="required" {% endif %}
|
||||||
|
for="{{ field.name }}-{{ forloop.counter0 }}">
|
||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</label>
|
</label>
|
||||||
{{ field|css_class:'form-control input-lg' }}
|
{{ field|css_class:'form-control input-lg' }}
|
||||||
|
@ -37,11 +42,9 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for error in field.errors %}
|
{% for error in field.errors %}
|
||||||
<hr>
|
<span class="help-block">
|
||||||
<div class="alert alert-danger alert-block">
|
{{ error }}
|
||||||
<span class="pficon pficon-error-circle-o"></span>
|
</span>
|
||||||
<strong>{{ error }}</strong>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
"""Core views"""
|
"""passbook core authentication views"""
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import login, logout
|
from django.contrib.auth import login, logout
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
|
from django.forms.utils import ErrorList
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, reverse
|
from django.shortcuts import get_object_or_404, redirect, reverse
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
@ -12,6 +13,7 @@ from django.views import View
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
|
|
||||||
from passbook.core.auth.view import AuthenticationView
|
from passbook.core.auth.view import AuthenticationView
|
||||||
|
from passbook.core.exceptions import PasswordPolicyInvalid
|
||||||
from passbook.core.forms.authentication import LoginForm, SignUpForm
|
from passbook.core.forms.authentication import LoginForm, SignUpForm
|
||||||
from passbook.core.models import Invitation, Nonce, Source, User
|
from passbook.core.models import Invitation, Nonce, Source, User
|
||||||
from passbook.core.signals import invitation_used, user_signed_up
|
from passbook.core.signals import invitation_used, user_signed_up
|
||||||
|
@ -141,7 +143,15 @@ class SignUpView(UserPassesTestMixin, FormView):
|
||||||
|
|
||||||
def form_valid(self, form: SignUpForm) -> HttpResponse:
|
def form_valid(self, form: SignUpForm) -> HttpResponse:
|
||||||
"""Create user"""
|
"""Create user"""
|
||||||
|
try:
|
||||||
self._user = SignUpView.create_user(form.cleaned_data, self.request)
|
self._user = SignUpView.create_user(form.cleaned_data, self.request)
|
||||||
|
except PasswordPolicyInvalid as exc:
|
||||||
|
# Manually inject error into form
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
errors = form._errors.setdefault("password", ErrorList())
|
||||||
|
for error in exc.messages:
|
||||||
|
errors.append(error)
|
||||||
|
return self.form_invalid(form)
|
||||||
needs_confirmation = True
|
needs_confirmation = True
|
||||||
if self._invitation and not self._invitation.needs_confirmation:
|
if self._invitation and not self._invitation.needs_confirmation:
|
||||||
needs_confirmation = False
|
needs_confirmation = False
|
||||||
|
@ -187,16 +197,18 @@ class SignUpView(UserPassesTestMixin, FormView):
|
||||||
The user created
|
The user created
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
SignalException: if any signals raise an exception. This also deletes the created user.
|
PasswordPolicyInvalid: if any policy are not fulfilled.
|
||||||
|
This also deletes the created user.
|
||||||
"""
|
"""
|
||||||
# Create user
|
# Create user
|
||||||
new_user = User.objects.create_user(
|
new_user = User.objects.create(
|
||||||
username=data.get('username'),
|
username=data.get('username'),
|
||||||
email=data.get('email'),
|
email=data.get('email'),
|
||||||
first_name=data.get('first_name'),
|
first_name=data.get('first_name'),
|
||||||
last_name=data.get('last_name'),
|
last_name=data.get('last_name'),
|
||||||
)
|
)
|
||||||
new_user.is_active = True
|
new_user.is_active = True
|
||||||
|
try:
|
||||||
new_user.set_password(data.get('password'))
|
new_user.set_password(data.get('password'))
|
||||||
new_user.save()
|
new_user.save()
|
||||||
request.user = new_user
|
request.user = new_user
|
||||||
|
@ -206,6 +218,9 @@ class SignUpView(UserPassesTestMixin, FormView):
|
||||||
user=new_user,
|
user=new_user,
|
||||||
request=request)
|
request=request)
|
||||||
return new_user
|
return new_user
|
||||||
|
except PasswordPolicyInvalid as exc:
|
||||||
|
new_user.delete()
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
class SignUpConfirmView(View):
|
class SignUpConfirmView(View):
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
"""passbook core user views"""
|
"""passbook core user views"""
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import logout, update_session_auth_hash
|
from django.contrib.auth import logout, update_session_auth_hash
|
||||||
|
from django.forms.utils import ErrorList
|
||||||
from django.shortcuts import redirect, reverse
|
from django.shortcuts import redirect, reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic import DeleteView, FormView, UpdateView
|
from django.views.generic import DeleteView, FormView, UpdateView
|
||||||
|
|
||||||
|
from passbook.core.exceptions import PasswordPolicyInvalid
|
||||||
from passbook.core.forms.users import PasswordChangeForm, UserDetailForm
|
from passbook.core.forms.users import PasswordChangeForm, UserDetailForm
|
||||||
from passbook.lib.config import CONFIG
|
from passbook.lib.config import CONFIG
|
||||||
|
|
||||||
|
@ -38,10 +40,20 @@ class UserChangePasswordView(FormView):
|
||||||
template_name = 'login/form_with_user.html'
|
template_name = 'login/form_with_user.html'
|
||||||
|
|
||||||
def form_valid(self, form: PasswordChangeForm):
|
def form_valid(self, form: PasswordChangeForm):
|
||||||
|
try:
|
||||||
self.request.user.set_password(form.cleaned_data.get('password'))
|
self.request.user.set_password(form.cleaned_data.get('password'))
|
||||||
self.request.user.save()
|
self.request.user.save()
|
||||||
update_session_auth_hash(self.request, self.request.user)
|
update_session_auth_hash(self.request, self.request.user)
|
||||||
messages.success(self.request, _('Successfully changed password'))
|
messages.success(self.request, _('Successfully changed password'))
|
||||||
|
except PasswordPolicyInvalid as exc:
|
||||||
|
# Manually inject error into form
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
errors = form._errors.setdefault("password_repeat", ErrorList(''))
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
errors = form._errors.setdefault("password", ErrorList())
|
||||||
|
for error in exc.messages:
|
||||||
|
errors.append(error)
|
||||||
|
return self.form_invalid(form)
|
||||||
return redirect('passbook_core:overview')
|
return redirect('passbook_core:overview')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"""passbook HIBP Models"""
|
"""passbook HIBP Models"""
|
||||||
|
|
||||||
from hashlib import sha1
|
from hashlib import sha1
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
@ -8,6 +8,7 @@ from requests import get
|
||||||
|
|
||||||
from passbook.core.models import Policy, User
|
from passbook.core.models import Policy, User
|
||||||
|
|
||||||
|
LOGGER = getLogger(__name__)
|
||||||
|
|
||||||
class HaveIBeenPwendPolicy(Policy):
|
class HaveIBeenPwendPolicy(Policy):
|
||||||
"""Check if password is on HaveIBeenPwned's list by upload the first
|
"""Check if password is on HaveIBeenPwned's list by upload the first
|
||||||
|
@ -33,8 +34,9 @@ class HaveIBeenPwendPolicy(Policy):
|
||||||
full_hash, count = line.split(':')
|
full_hash, count = line.split(':')
|
||||||
if pw_hash[5:] == full_hash.lower():
|
if pw_hash[5:] == full_hash.lower():
|
||||||
final_count = int(count)
|
final_count = int(count)
|
||||||
|
LOGGER.debug("Got count %d for hash %s", final_count, pw_hash[:5])
|
||||||
if final_count > self.allowed_count:
|
if final_count > self.allowed_count:
|
||||||
return False
|
return False, _("Password exists on %(count)d online lists." % {'count': final_count})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
Reference in a new issue