diff --git a/docker/settings.py b/docker/settings.py index 2a7f2d2..7ead298 100644 --- a/docker/settings.py +++ b/docker/settings.py @@ -279,6 +279,7 @@ CACHES = { } } AXES_CACHE = "axes_cache" +AXES_LOCKOUT_TEMPLATE = "registration/account_lockout.html" # Full path to directory to store logs for dynamically executed tasks TASK_LOG_DIR = "/tmp/layerindex-task-logs" diff --git a/layerindex/admin.py b/layerindex/admin.py index cb15dea..3b145ef 100644 --- a/layerindex/admin.py +++ b/layerindex/admin.py @@ -236,3 +236,6 @@ admin.site.register(ClassicRecipe, ClassicRecipeAdmin) admin.site.register(ComparisonRecipeUpdate, ComparisonRecipeUpdateAdmin) admin.site.register(PythonEnvironment) admin.site.register(SiteNotice) +admin.site.register(SecurityQuestion) +admin.site.register(SecurityQuestionAnswer) +admin.site.register(UserProfile) diff --git a/layerindex/auth_forms.py b/layerindex/auth_forms.py index 3bdbc34..8a0fb63 100644 --- a/layerindex/auth_forms.py +++ b/layerindex/auth_forms.py @@ -4,19 +4,58 @@ # # Licensed under the MIT license, see COPYING.MIT for details -from django import forms from captcha.fields import CaptchaField -from django_registration.forms import RegistrationForm -from django.contrib.auth.forms import PasswordResetForm -from django.contrib.auth.models import User +from django import forms +from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm from django.contrib.auth.hashers import check_password +from django.contrib.auth.models import User +from django_registration.forms import RegistrationForm + +from layerindex.models import SecurityQuestion class CaptchaRegistrationForm(RegistrationForm): - captcha = CaptchaField(label='Verification', help_text='Please enter the letters displayed for verification purposes', error_messages={'invalid':'Incorrect entry, please try again'}) + captcha = CaptchaField(label='Verification', + help_text='Please enter the letters displayed for verification purposes', + error_messages={'invalid':'Incorrect entry, please try again'}) + security_question_1 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_1 = forms.CharField(widget=forms.TextInput(), label='Answer', required=True) + security_question_2 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_2 = forms.CharField(widget=forms.TextInput(), label='Answer', required=True) + security_question_3 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_3 = forms.CharField(widget=forms.TextInput(), label='Answer', required=True) + + def __init__(self, *args, **kwargs): + super(CaptchaRegistrationForm, self ).__init__(*args, **kwargs) + self.fields['security_question_1'].initial=SecurityQuestion.objects.all()[0] + self.fields['security_question_2'].initial=SecurityQuestion.objects.all()[1] + self.fields['security_question_3'].initial=SecurityQuestion.objects.all()[2] + + def clean(self): + cleaned_data = super(CaptchaRegistrationForm, self).clean() + security_question_1 = self.cleaned_data["security_question_1"] + security_question_2 = self.cleaned_data["security_question_2"] + security_question_3 = self.cleaned_data["security_question_3"] + if security_question_1 == security_question_2: + raise forms.ValidationError({'security_question_2': ["Questions may only be chosen once."]}) + if security_question_1 == security_question_3 or security_question_2 == security_question_3: + raise forms.ValidationError({'security_question_3': ["Questions may only be chosen once."]}) + return cleaned_data + + class Meta: + model = User + fields = [ + User.USERNAME_FIELD, + 'email', + 'password1', + 'password2', + ] + class CaptchaPasswordResetForm(PasswordResetForm): - captcha = CaptchaField(label='Verification', help_text='Please enter the letters displayed for verification purposes', error_messages={'invalid':'Incorrect entry, please try again'}) + captcha = CaptchaField(label='Verification', + help_text='Please enter the letters displayed for verification purposes', + error_messages={'invalid':'Incorrect entry, please try again'}) class DeleteAccountForm(forms.ModelForm): @@ -32,3 +71,65 @@ class DeleteAccountForm(forms.ModelForm): if not check_password(confirm_password, self.instance.password): self.add_error('confirm_password', 'Password does not match.') return cleaned_data + + +class SecurityQuestionPasswordResetForm(SetPasswordForm): + correct_answers = 0 + security_question_1 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_1 = forms.CharField(widget=forms.TextInput(), label='Answer', required=True,) + security_question_2 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_2 = forms.CharField(widget=forms.TextInput(), label='Answer', required=True) + security_question_3 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_3 = forms.CharField(widget=forms.TextInput(), label='Answer', required=True) + + def __init__(self, *args, **kwargs): + super(SecurityQuestionPasswordResetForm, self ).__init__(*args, **kwargs) + self.fields['security_question_1'].initial=SecurityQuestion.objects.all()[0] + self.fields['security_question_2'].initial=SecurityQuestion.objects.all()[1] + self.fields['security_question_3'].initial=SecurityQuestion.objects.all()[2] + + def clean_answer_util(self, question, answer): + form_security_question = self.cleaned_data[question] + form_answer = self.cleaned_data[answer].replace(" ", "").lower() + # Attempt to get the user's hashed answer to the security question. If the user didn't choose + # this security question, throw an exception. + try: + question_answer = self.user.userprofile.securityquestionanswer_set.filter( + security_question__question=form_security_question)[0] + except IndexError as e: + raise forms.ValidationError("Security question is incorrect.") + user_answer = question_answer.answer + + # Compare input answer to hashed database answer. + if check_password(form_answer, user_answer): + self.correct_answers = self.correct_answers+1 + return form_answer + + def clean_answer_1(self): + return self.clean_answer_util("security_question_1", "answer_1") + + def clean_answer_2(self): + return self.clean_answer_util("security_question_2", "answer_2") + + def clean_answer_3(self): + return self.clean_answer_util("security_question_3", "answer_3") + + def clean(self): + # We require two correct security questions. If less than two are correct, the user gets + # one additional attempt before their account is locked out. + answer_attempts = self.user.userprofile.answer_attempts + if self.correct_answers < 2: + if answer_attempts == 0: + self.user.userprofile.answer_attempts = self.user.userprofile.answer_attempts + 1 + self.user.userprofile.save() + raise forms.ValidationError("One or more security answers are incorrect.", code="incorrect_answers") + else : + # Reset answer attempts to 0 and throw error to lock account. + self.user.userprofile.answer_attempts = 0 + self.user.userprofile.save() + raise forms.ValidationError("Too many attempts! Your account has been locked. " + "Please contact your admin.", code="account_locked") + + else: + self.user.userprofile.answer_attempts = 0 + self.user.userprofile.save() diff --git a/layerindex/auth_views.py b/layerindex/auth_views.py index d757db3..065dc54 100644 --- a/layerindex/auth_views.py +++ b/layerindex/auth_views.py @@ -4,20 +4,56 @@ # # Licensed under the MIT license, see COPYING.MIT for details -from django.core.urlresolvers import reverse -from django.http import HttpResponseRedirect -from django.core.exceptions import PermissionDenied -from django.shortcuts import render from django.contrib import messages from django.contrib.auth import logout -from django_registration.backends.activation.views import RegistrationView -from django.contrib.auth.views import PasswordResetView -from layerindex.auth_forms import CaptchaRegistrationForm, CaptchaPasswordResetForm, DeleteAccountForm +from django.contrib.auth.hashers import make_password +from django.contrib.auth.views import (PasswordResetConfirmView, + PasswordResetView) +from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import PermissionDenied +from django.core.urlresolvers import reverse +from django.http import HttpResponseRedirect, HttpResponse +from django.shortcuts import render +from django_registration import signals +from django_registration.backends.activation.views import RegistrationView + +from layerindex.auth_forms import (CaptchaPasswordResetForm, + CaptchaRegistrationForm, DeleteAccountForm, + SecurityQuestionPasswordResetForm) + +from .models import SecurityQuestion, SecurityQuestionAnswer, UserProfile +from . import tasks +import settings class CaptchaRegistrationView(RegistrationView): form_class = CaptchaRegistrationForm + def register(self, form): + new_user = self.create_inactive_user(form) + signals.user_registered.send( + sender=self.__class__, + user=new_user, + request=self.request + ) + + # Add security question answers to the database + security_question_1 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_1")) + security_question_2 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_2")) + security_question_3 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_3")) + answer_1 = form.cleaned_data.get("answer_1").replace(" ", "").lower() + answer_2 = form.cleaned_data.get("answer_2").replace(" ", "").lower() + answer_3 = form.cleaned_data.get("answer_3").replace(" ", "").lower() + + user = UserProfile.objects.create(user=new_user) + # Answers are hashed using Django's password hashing function make_password() + SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_1, + answer=make_password(answer_1)) + SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_2, + answer=make_password(answer_2)) + SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_3, + answer=make_password(answer_3)) + def get_context_data(self, **kwargs): context = super(CaptchaRegistrationView, self).get_context_data(**kwargs) form = context['form'] @@ -61,3 +97,39 @@ def delete_account_view(request, template_name): 'form': form, }) + +class PasswordResetSecurityQuestions(PasswordResetConfirmView): + form_class = SecurityQuestionPasswordResetForm + + def get(self, request, *args, **kwargs): + try: + self.user.userprofile + except UserProfile.DoesNotExist: + return HttpResponseRedirect(reverse('password_reset_fail')) + if not self.user.is_active: + return HttpResponseRedirect(reverse('account_lockout')) + + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + form = self.form_class(data=request.POST, user=self.user) + form.is_valid() + for error in form.non_field_errors().as_data(): + if error.code == "account_locked": + # Deactivate user's account. + self.user.is_active = False + self.user.save() + # Send admin an email that user is locked out. + site_name = get_current_site(request).name + subject = "User account locked on " + site_name + text_content = "User " + self.user.username + " has been locked out on " + site_name + "." + admins = settings.ADMINS + from_email = settings.DEFAULT_FROM_EMAIL + tasks.send_email.apply_async((subject, text_content, from_email, [a[1] for a in admins])) + return HttpResponseRedirect(reverse('account_lockout')) + + if error.code == "incorrect_answers": + # User has failed first attempt at answering questions, give them another try. + return self.form_invalid(form) + + return super().post(request, *args, **kwargs) diff --git a/layerindex/forms.py b/layerindex/forms.py index 833474a..7aacc58 100644 --- a/layerindex/forms.py +++ b/layerindex/forms.py @@ -1,20 +1,28 @@ # layerindex-web - form definitions # -# Copyright (C) 2013 Intel Corporation +# Copyright (C) 2013, 2016-2019 Intel Corporation # # Licensed under the MIT license, see COPYING.MIT for details +import re from collections import OrderedDict -from layerindex.models import LayerItem, LayerBranch, LayerMaintainer, LayerNote, RecipeChangeset, RecipeChange, ClassicRecipe -from django import forms -from django.core.validators import URLValidator, RegexValidator, EmailValidator -from django_registration.validators import ReservedNameValidator, DEFAULT_RESERVED_NAMES, validate_confusables -from django.forms.models import inlineformset_factory, modelformset_factory + from captcha.fields import CaptchaField +from django import forms from django.contrib.auth.models import User from django.core.cache import cache -import re +from django.core.validators import EmailValidator, RegexValidator, URLValidator +from django.forms.models import inlineformset_factory, modelformset_factory +from django_registration.forms import RegistrationForm +from django_registration.validators import (DEFAULT_RESERVED_NAMES, + ReservedNameValidator, + validate_confusables) + import settings +from layerindex.models import (Branch, ClassicRecipe, + LayerBranch, LayerItem, LayerMaintainer, + LayerNote, RecipeChange, RecipeChangeset, + SecurityQuestion, UserProfile) class StyledForm(forms.Form): @@ -181,11 +189,31 @@ class EditNoteForm(StyledModelForm): class EditProfileForm(StyledModelForm): captcha = CaptchaField(label='Verification', help_text='Please enter the letters displayed for verification purposes', error_messages={'invalid':'Incorrect entry, please try again'}) + security_question_1 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_1 = forms.CharField(widget=forms.TextInput(), label='Answer', initial="*****") + security_question_2 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_2 = forms.CharField(widget=forms.TextInput(), label='Answer', initial="*****") + security_question_3 = forms.ModelChoiceField(queryset=SecurityQuestion.objects.all()) + answer_3 = forms.CharField(widget=forms.TextInput(), label='Answer', initial="*****") class Meta: model = User fields = ('username', 'first_name', 'last_name', 'email', 'captcha') + def __init__(self, *args, **kwargs): + super(EditProfileForm, self ).__init__(*args, **kwargs) + user = kwargs.get("instance") + try: + self.fields['security_question_1'].initial=user.userprofile.securityquestionanswer_set.all()[0].security_question + self.fields['security_question_2'].initial=user.userprofile.securityquestionanswer_set.all()[1].security_question + self.fields['security_question_3'].initial=user.userprofile.securityquestionanswer_set.all()[2].security_question + except UserProfile.DoesNotExist: + # The super user won't have had security questions created already + self.fields['security_question_1'].initial=SecurityQuestion.objects.all()[0] + self.fields['security_question_2'].initial=SecurityQuestion.objects.all()[1] + self.fields['security_question_3'].initial=SecurityQuestion.objects.all()[2] + pass + def clean_username(self): username = self.cleaned_data['username'] if 'username' in self.changed_data: @@ -208,6 +236,25 @@ class EditProfileForm(StyledModelForm): return username + def clean(self): + cleaned_data = super(EditProfileForm, self).clean() + for data in self.changed_data: + # Check if a security answer has been updated. If one is updated, they must all be + # and each security question must be unique. + if 'answer' in data: + if 'answer_1' not in self.changed_data \ + or 'answer_2' not in self.changed_data \ + or 'answer_3' not in self.changed_data: + raise forms.ValidationError("Please answer three security questions.") + security_question_1 = self.cleaned_data["security_question_1"] + security_question_2 = self.cleaned_data["security_question_2"] + security_question_3 = self.cleaned_data["security_question_3"] + if security_question_1 == security_question_2: + raise forms.ValidationError({'security_question_2': ["Questions may only be chosen once."]}) + if security_question_1 == security_question_3 or security_question_2 == security_question_3: + raise forms.ValidationError({'security_question_3': ["Questions may only be chosen once."]}) + return cleaned_data + class ClassicRecipeForm(StyledModelForm): class Meta: diff --git a/layerindex/migrations/0030_securityquestion.py b/layerindex/migrations/0030_securityquestion.py new file mode 100644 index 0000000..4f4db0f --- /dev/null +++ b/layerindex/migrations/0030_securityquestion.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.17 on 2019-01-08 22:30 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('layerindex', '0028_branch_hidden'), + ] + + operations = [ + migrations.CreateModel( + name='SecurityQuestion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', models.CharField(max_length=250)), + ], + ), + migrations.CreateModel( + name='SecurityQuestionAnswer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answer', models.CharField(max_length=250)), + ('security_question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layerindex.SecurityQuestion')), + ], + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answer_attempts', models.IntegerField(default=0)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='securityquestionanswer', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='layerindex.UserProfile'), + ), + ] diff --git a/layerindex/migrations/0031_securityquestion_populate.py b/layerindex/migrations/0031_securityquestion_populate.py new file mode 100644 index 0000000..88a2a56 --- /dev/null +++ b/layerindex/migrations/0031_securityquestion_populate.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.17 on 2019-01-03 01:25 +from __future__ import unicode_literals + +from django.db import migrations +from layerindex.securityquestions import security_questions + + +def populate_security_questions(apps, schema_editor): + SecurityQuestion = apps.get_model('layerindex', 'SecurityQuestion') + + for question in security_questions: + securityquestion = SecurityQuestion() + securityquestion.question = question + securityquestion.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('layerindex', '0030_securityquestion'), + ] + + operations = [ + migrations.RunPython(populate_security_questions), + ] diff --git a/layerindex/models.py b/layerindex/models.py index 330b7a4..e7bb04d 100644 --- a/layerindex/models.py +++ b/layerindex/models.py @@ -864,3 +864,21 @@ class SiteNotice(models.Model): def text_sanitised(self): return utils.sanitise_html(self.text) + + +class SecurityQuestion(models.Model): + question = models.CharField(max_length = 250, null=False) + + def __str__(self): + return '%s' % (self.question) + + +class UserProfile(models.Model): + user = models.OneToOneField(User) + answer_attempts = models.IntegerField(default=0) + + +class SecurityQuestionAnswer(models.Model): + user = models.ForeignKey(UserProfile, on_delete=models.CASCADE) + security_question = models.ForeignKey(SecurityQuestion) + answer = models.CharField(max_length = 250, null=False) diff --git a/layerindex/securityquestions.py b/layerindex/securityquestions.py new file mode 100644 index 0000000..a6a0e1c --- /dev/null +++ b/layerindex/securityquestions.py @@ -0,0 +1,6 @@ +security_questions = ["What was the name of your first pet?", + "What was the last name of your third grade teacher?", + "What is your favorite pizza topping?", + "What is your favorite band?", + "What street did you grow up on?", + ] diff --git a/layerindex/views.py b/layerindex/views.py index 8f42e52..f49494c 100644 --- a/layerindex/views.py +++ b/layerindex/views.py @@ -36,8 +36,8 @@ from django.views.generic import DetailView, ListView, TemplateView from django.views.generic.base import RedirectView from django.views.generic.edit import (CreateView, DeleteView, FormView, UpdateView) - from django_registration.backends.activation.views import RegistrationView +from pkg_resources import parse_version from reversion.models import Revision import settings @@ -51,7 +51,9 @@ from layerindex.models import (BBAppend, BBClass, Branch, ClassicRecipe, LayerDependency, LayerItem, LayerMaintainer, LayerNote, LayerUpdate, Machine, Patch, Recipe, RecipeChange, RecipeChangeset, Source, StaticBuildDep, - Update) + Update, SecurityQuestion, SecurityQuestionAnswer, + UserProfile) + from . import simplesearch, tasks, utils @@ -891,6 +893,31 @@ class EditProfileFormView(SuccessMessageMixin, UpdateView): def form_valid(self, form): self.object = form.save() + + if'answer_1' in form.changed_data: + # If one security answer has changed, they all have. Delete current questions and add new ones. + # Don't throw an error if we are editing the super user and they don't have security questions yet. + try: + self.user.userprofile.securityquestionanswer_set.all().delete() + user = self.user.userprofile + except UserProfile.DoesNotExist: + user = UserProfile.objects.create(user=self.user) + + security_question_1 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_1")) + security_question_2 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_2")) + security_question_3 = SecurityQuestion.objects.get(question=form.cleaned_data.get("security_question_3")) + answer_1 = form.cleaned_data.get("answer_1").replace(" ", "").lower() + answer_2 = form.cleaned_data.get("answer_2").replace(" ", "").lower() + answer_3 = form.cleaned_data.get("answer_3").replace(" ", "").lower() + + # Answers are hashed using Django's password hashing function make_password() + SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_1, + answer=make_password(answer_1)) + SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_2, + answer=make_password(answer_2)) + SecurityQuestionAnswer.objects.create(user=user, security_question=security_question_3, + answer=make_password(answer_3)) + if 'email' in form.changed_data: # Take a copy of request.user as it is about to be invalidated by logout() user = self.request.user diff --git a/settings.py b/settings.py index 8b0a37c..41199a1 100644 --- a/settings.py +++ b/settings.py @@ -278,6 +278,7 @@ CACHES = { } } AXES_CACHE = "axes_cache" +AXES_LOCKOUT_TEMPLATE = "registration/account_lockout.html" # Full path to directory to store logs for dynamically executed tasks TASK_LOG_DIR = "/tmp/layerindex-task-logs" diff --git a/templates/layerindex/profile.html b/templates/layerindex/profile.html index 04794ce..b45f16d 100644 --- a/templates/layerindex/profile.html +++ b/templates/layerindex/profile.html @@ -25,6 +25,12 @@ {{ hidden }} {% endfor %} + {% if form.non_field_errors %} +
{% trans "Your account has been locked out. Please contact the admin." %}
+{% endblock %} diff --git a/templates/registration/logout.html b/templates/registration/logged_out.html similarity index 100% rename from templates/registration/logout.html rename to templates/registration/logged_out.html diff --git a/templates/registration/password_reset_confirm.html b/templates/registration/password_reset_confirm.html index 20b8e01..e6eb9cb 100644 --- a/templates/registration/password_reset_confirm.html +++ b/templates/registration/password_reset_confirm.html @@ -10,6 +10,12 @@ {{ hidden }} {% endfor %} + {% if form.non_field_errors %} +{% trans "Password reset failed. You haven't set security questions, so we cannot reset your password." %}
+{% endblock %} diff --git a/urls.py b/urls.py index c137f1d..41324da 100644 --- a/urls.py +++ b/urls.py @@ -8,8 +8,8 @@ from django.conf.urls import include, url from django.core.urlresolvers import reverse_lazy from django.views.generic import RedirectView, TemplateView -from layerindex.auth_views import CaptchaRegistrationView, CaptchaPasswordResetView, delete_account_view - +from layerindex.auth_views import CaptchaRegistrationView, CaptchaPasswordResetView, delete_account_view, \ + PasswordResetSecurityQuestions from django.contrib import admin admin.autodiscover() @@ -18,7 +18,7 @@ import settings urlpatterns = [ url(r'^layerindex/', include('layerindex.urls')), url(r'^admin/', include(admin.site.urls)), - url(r'^accounts/password/reset/$', + url(r'^accounts/password_reset/$', CaptchaPasswordResetView.as_view( email_template_name='registration/password_reset_email.txt', success_url=reverse_lazy('password_reset_done')), @@ -31,11 +31,20 @@ urlpatterns = [ url(r'^accounts/reregister/$', TemplateView.as_view( template_name='registration/reregister.html'), name='reregister'), + url(r'^accounts/reset/(?P