Add user security questions

Add user security questions upon registration as extra authentication
for password reset. Three unique security questions must be chosen and
answered. Answers are then stored in the database with the same hashing
algorithm as the users's password.

On password reset, users get two chances to get two out of three
security questions answered correctly. After a second failure their
account is locked and email is sent to the admin. The same template is
shown for the axes lockout. Super user cannot reset their password until
they set security questions.

Users can update their security questions or add them if they weren't
originally set (in the case of super user) in Edit Profile.

Signed-off-by: Amber Elliot <amber.n.elliot@intel.com>
This commit is contained in:
Amber Elliot 2018-11-27 12:07:04 -08:00 committed by Paul Eggleton
parent 0f3b3e42a6
commit 9a9bbeb8b6
17 changed files with 406 additions and 26 deletions

View File

@ -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"

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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'),
),
]

View File

@ -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),
]

View File

@ -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)

View File

@ -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?",
]

View File

@ -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

View File

@ -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"

View File

@ -25,6 +25,12 @@
{{ hidden }}
{% endfor %}
{% if form.non_field_errors %}
<div class="form-group alert alert-danger">
{{ form.non_field_errors }}
</div>
{% endif %}
{% for field in form.visible_fields %}
{% if field.name in error_fields %}
<div class="form-group alert alert-danger">

View File

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<p>{% trans "Your account has been locked out. Please contact the admin." %}</p>
{% endblock %}

View File

@ -10,6 +10,12 @@
{{ hidden }}
{% endfor %}
{% if form.non_field_errors %}
<div class="form-group alert alert-danger">
{{ form.non_field_errors }}
</div>
{% endif %}
{% for field in form.visible_fields %}
{% if field.name in form.errors %}
<div class="form-group alert alert-danger">

View File

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<p>{% trans "Password reset failed. You haven't set security questions, so we cannot reset your password." %}</p>
{% endblock %}

17
urls.py
View File

@ -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<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,3}-[0-9A-Za-z]{1,20})/$',
PasswordResetSecurityQuestions.as_view(),
name='password_reset_confirm',
),
url(r'^accounts/reset/fail/$', TemplateView.as_view(
template_name='registration/password_reset_fail.html'),
name='password_reset_fail'),
url(r'^accounts/lockout/$', TemplateView.as_view(
template_name='registration/account_lockout.html'),
name='account_lockout'),
url(r'^accounts/', include('django_registration.backends.activation.urls')),
url(r'^accounts/', include('django.contrib.auth.urls')),
url(r'^captcha/', include('captcha.urls')),
]
if 'rrs' in settings.INSTALLED_APPS:
urlpatterns += [
url(r'^rrs/', include('rrs.urls')),