Merge pull request 'Enforce stronger Passwords' (#38) from heinzel_dav_auth into master
All checks were successful
buildbot/tox Build done.

Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
2020-12-22 18:48:46 +01:00
16 changed files with 549 additions and 46 deletions

View File

@@ -62,7 +62,7 @@ class SetPasswordForm(forms.Form):
return self.user return self.user
class ResetPasswordForm(forms.Form): class CreateAndSendPasswordForm(forms.Form):
username = auth_forms.UsernameField( username = auth_forms.UsernameField(
max_length=254, max_length=254,
label=_(u'E-Mail-Adresse'), label=_(u'E-Mail-Adresse'),

View File

@@ -15,7 +15,8 @@
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
{% bootstrap_form form %} {% bootstrap_form form %}
<div class="pull-right"><a href="{% url 'dav_auth:reset_password' %}">{% trans 'Passwort vergessen?' %}</a></div> <div class="pull-right"><a
href="{% url 'dav_auth:recreate_password' %}">{% trans 'Passwort vergessen?' %}</a></div>
{% buttons %} {% buttons %}
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
{% bootstrap_icon 'log-in' %}&thinsp; {% bootstrap_icon 'log-in' %}&thinsp;

View File

@@ -32,4 +32,38 @@
&nbsp; &nbsp;
</div> </div>
</div> </div>
<div class="row">
<div class="col-sm-2">
&nbsp;
</div>
<div class="col-sm-8">
<h3 class="top-most">{% trans 'Passwortrichtlinien' %}</h3>
<div class="well">
<p>
Damit die persönlichen Daten unserer Teilnehmer jederzeit geschützt sind, ist es unabdingbar,
dass eure Passwörter für das Touren- & Kurse-Portal nicht einfach zu erraten sind.
</p>
<p>
Je länger das Passwort ist und je mehr <i>ungewöhnliche</i> Zeichen darin enthalten sind,
umso unwahrscheinlicher ist es, dass das Passwort erraten werden kann.
</p>
<p>
Um sicher zu stellen, dass eure Passwörter ausreichend lang sind und nicht nur
aus einfachen Wörtern bestehen, wird die Güte eures Passwortes mit Punkten bewertet.<br />
Ausserdem dürfen bestimmte Wörter nicht enthalten sein (z.B. Karlsruhe).
</p>
<p>
Mehr Zeichen geben mehr Punkte. Und Großbuchstaben, Ziffern und Sonderzeichen geben Extrapunkte.
</p>
<p>
Im Regelfall sollte euer Passwort aus mindestens 12 Zeichen bestehen,
darunter mindestens je zwei Großbuchstaben, Ziffern und Sonderzeichen.<br />
Nehmt da ruhig alles, was eure Tastatur so hergibt.
</p>
</div>
</div>
<div class="col-sm-3">
&nbsp;
</div>
</div>
{% endblock page-container %} {% endblock page-container %}

View File

@@ -9,7 +9,7 @@ from ..emails import PasswordSetEmail
TEST_USERNAME = 'user' TEST_USERNAME = 'user'
TEST_PASSWORD = u'me||ön 2' TEST_PASSWORD = u'me||ön 21ABll'
TEST_EMAIL = 'root@localhost' TEST_EMAIL = 'root@localhost'
PASSWORD_EMAIL_TEMPLATE = u"""Hallo {fullname}, PASSWORD_EMAIL_TEMPLATE = u"""Hallo {fullname},

View File

@@ -6,10 +6,10 @@ from django.utils.translation import ugettext
from dav_base.tests.generic import FormDataSet, FormsTestCase from dav_base.tests.generic import FormDataSet, FormsTestCase
from ..forms import LoginForm, SetPasswordForm, ResetPasswordForm from ..forms import LoginForm, SetPasswordForm, CreateAndSendPasswordForm
TEST_USERNAME = 'root@localhost' TEST_USERNAME = 'root@localhost'
TEST_PASSWORD = u'me||ön 2' TEST_PASSWORD = u'me||ön 21ABll'
TEST_EMAIL = TEST_USERNAME TEST_EMAIL = TEST_USERNAME
USERNAME_MAX_LENGTH = 254 USERNAME_MAX_LENGTH = 254
@@ -108,7 +108,7 @@ class SetPasswordFormTestCase(FormsTestCase):
def test_mismatch(self): def test_mismatch(self):
data_sets = [ data_sets = [
FormDataSet({'new_password': 'mellon12', 'new_password_repeat': 'mellon13'}, FormDataSet({'new_password': 'mellonAB12+-', 'new_password_repeat': 'mellonAB13+-'},
[('new_password_repeat', 'password_mismatch')]), [('new_password_repeat', 'password_mismatch')]),
] ]
super(SetPasswordFormTestCase, self).test_invalid_data(data_sets=data_sets, form_kwargs={'user': self.user}) super(SetPasswordFormTestCase, self).test_invalid_data(data_sets=data_sets, form_kwargs={'user': self.user})
@@ -150,9 +150,10 @@ class SetPasswordFormTestCase(FormsTestCase):
def test_valid(self): def test_valid(self):
data_sets = [ data_sets = [
FormDataSet({'new_password': 'mellon12', 'new_password_repeat': 'mellon12'}), FormDataSet({'new_password': 'mellonAB12+-', 'new_password_repeat': 'mellonAB12+-'}),
FormDataSet({'new_password': 'mellon12', 'new_password_repeat': 'mellon12', 'send_password_mail': True}), FormDataSet({'new_password': 'mellonAB12+-', 'new_password_repeat': 'mellonAB12+-',
FormDataSet({'new_password': u'"ä§ Mellon12', 'new_password_repeat': u'"ä§ Mellon12'}), 'send_password_mail': True}),
FormDataSet({'new_password': u'"ä§ MellonAB12+-', 'new_password_repeat': u'"ä§ MellonAB12+-'}),
FormDataSet({'new_password': 'mellon12' * 128, 'new_password_repeat': 'mellon12' * 128}), FormDataSet({'new_password': 'mellon12' * 128, 'new_password_repeat': 'mellon12' * 128}),
] ]
super(SetPasswordFormTestCase, self).test_valid_data(data_sets=data_sets, form_kwargs={'user': self.user}) super(SetPasswordFormTestCase, self).test_valid_data(data_sets=data_sets, form_kwargs={'user': self.user})
@@ -193,8 +194,8 @@ class SetPasswordFormTestCase(FormsTestCase):
self.assertTrue(self.client.login(username=self.test_username, password=new_password)) self.assertTrue(self.client.login(username=self.test_username, password=new_password))
class ResetPasswordFormTestCase(FormsTestCase): class CreateAndSendPasswordFormTestCase(FormsTestCase):
form_class = ResetPasswordForm form_class = CreateAndSendPasswordForm
valid_data_sets = ( valid_data_sets = (
FormDataSet({'username': 'unittest@example.com'}), FormDataSet({'username': 'unittest@example.com'}),

View File

@@ -9,7 +9,7 @@ from selenium.webdriver.common.keys import Keys
from dav_base.tests.generic import ScreenshotTestCase from dav_base.tests.generic import ScreenshotTestCase
TEST_USERNAME = 'root@localhost' TEST_USERNAME = 'root@localhost'
TEST_PASSWORD = u'me||ön 2' TEST_PASSWORD = u'me||ön 21ABll'
TEST_EMAIL = TEST_USERNAME TEST_EMAIL = TEST_USERNAME
@@ -177,10 +177,10 @@ class TestCase(ScreenshotTestCase):
self.wait_until_stale(c, password2_field) self.wait_until_stale(c, password2_field)
self.save_screenshot('set_password_succeed', sequence=sequence_name) self.save_screenshot('set_password_succeed', sequence=sequence_name)
# Get password reset page -> since we are logged in, it should # Get password recreate page -> since we are logged in, it should
# redirect to set password page again -> save # redirect to set password page again -> save
html = c.find_element_by_tag_name('html') html = c.find_element_by_tag_name('html')
c.get(self.complete_url(reverse('dav_auth:reset_password'))) c.get(self.complete_url(reverse('dav_auth:recreate_password')))
self.wait_until_stale(c, html) self.wait_until_stale(c, html)
self.wait_on_presence(c, (By.ID, 'id_new_password')) self.wait_on_presence(c, (By.ID, 'id_new_password'))
self.save_screenshot('empty_set_password_form', sequence=sequence_name) self.save_screenshot('empty_set_password_form', sequence=sequence_name)
@@ -194,24 +194,24 @@ class TestCase(ScreenshotTestCase):
self.wait_until_stale(c, user_menu) self.wait_until_stale(c, user_menu)
self.save_screenshot('logout_succeed', sequence=sequence_name) self.save_screenshot('logout_succeed', sequence=sequence_name)
# Click on 'login' to access password reset link # Click on 'login' to access password recreate link
link = c.find_element_by_css_selector('#login-widget a') link = c.find_element_by_css_selector('#login-widget a')
link.click() link.click()
self.wait_on_presence(c, (By.ID, 'id_username')) self.wait_on_presence(c, (By.ID, 'id_username'))
# Locate password reset link, click it -> save password reset form # Locate password recreate link, click it -> save password recreate form
link = c.find_element_by_partial_link_text(ugettext(u'Passwort vergessen')) link = c.find_element_by_partial_link_text(ugettext(u'Passwort vergessen'))
link.click() link.click()
username_field = self.wait_on_presence(c, (By.ID, 'id_username')) username_field = self.wait_on_presence(c, (By.ID, 'id_username'))
self.save_screenshot('empty_reset_password_form', sequence=sequence_name) self.save_screenshot('empty_recreate_password_form', sequence=sequence_name)
# Enter invalid username -> save result (login form, no message) # Enter invalid username -> save result (login form, no message)
username_field.send_keys(self.test_username[::-1]) username_field.send_keys(self.test_username[::-1])
username_field.send_keys(Keys.RETURN) username_field.send_keys(Keys.RETURN)
self.wait_until_stale(c, username_field) self.wait_until_stale(c, username_field)
self.save_screenshot('reset_password_invalid_user', sequence=sequence_name) self.save_screenshot('recreate_password_invalid_user', sequence=sequence_name)
# Locate password reset link, click it # Locate password recreate link, click it
link = c.find_element_by_partial_link_text(ugettext(u'Passwort vergessen')) link = c.find_element_by_partial_link_text(ugettext(u'Passwort vergessen'))
link.click() link.click()
username_field = self.wait_on_presence(c, (By.ID, 'id_username')) username_field = self.wait_on_presence(c, (By.ID, 'id_username'))
@@ -220,4 +220,4 @@ class TestCase(ScreenshotTestCase):
username_field.send_keys(self.test_username) username_field.send_keys(self.test_username)
username_field.send_keys(Keys.RETURN) username_field.send_keys(Keys.RETURN)
self.wait_until_stale(c, username_field) self.wait_until_stale(c, username_field)
self.save_screenshot('reset_password_valid_user', sequence=sequence_name) self.save_screenshot('recreate_password_valid_user', sequence=sequence_name)

View File

@@ -12,16 +12,16 @@ from dav_base.tests.generic import SeleniumTestCase
from .generic import SeleniumAuthMixin from .generic import SeleniumAuthMixin
TEST_USERNAME = 'root@localhost' TEST_USERNAME = 'root@localhost'
TEST_PASSWORD = 'me||ön 2' TEST_PASSWORD = 'me||ön 21ABll'
TEST_EMAIL = TEST_USERNAME TEST_EMAIL = TEST_USERNAME
class TemplatesTestCase(SimpleTestCase): class TemplatesTestCase(SimpleTestCase):
def test_reset_link_in_login_form(self): def test_recreate_link_in_login_form(self):
login_url = reverse('dav_auth:login') login_url = reverse('dav_auth:login')
reset_url = reverse('dav_auth:reset_password') recreate_url = reverse('dav_auth:recreate_password')
text = ugettext('Passwort vergessen?') text = ugettext('Passwort vergessen?')
html = '<a href="{url}">{text}</a>'.format(url=reset_url, text=text) html = '<a href="{url}">{text}</a>'.format(url=recreate_url, text=text)
response = self.client.get(login_url) response = self.client.get(login_url)
self.assertInHTML(html, response.content.decode('utf-8')) self.assertInHTML(html, response.content.decode('utf-8'))

View File

@@ -9,5 +9,5 @@ class TestCase(UrlsTestCase):
Url('/auth/logout', 'dav_auth:logout', views.LogoutView.as_view(), status_code=302), Url('/auth/logout', 'dav_auth:logout', views.LogoutView.as_view(), status_code=302),
Url('/auth/password', 'dav_auth:set_password', views.SetPasswordView.as_view(), Url('/auth/password', 'dav_auth:set_password', views.SetPasswordView.as_view(),
redirect='/auth/login?next=/auth/password'), redirect='/auth/login?next=/auth/password'),
Url('/auth/password/reset', 'dav_auth:reset_password', views.ResetPasswordView.as_view()), Url('/auth/password/recreate', 'dav_auth:recreate_password', views.CreateAndSendPasswordView.as_view()),
) )

View File

@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from ..validators import PasswordScoreValidator, CustomWordlistPasswordValidator, CharacterClassPasswordValidator
class PasswordScoreValidatorTestCase(SimpleTestCase):
def test_too_little_score(self):
passwords = [
u'', # score = 0
u'abcdefghijklmnopq', # score = 17
u'Abcdefghijklmnop', # score = 16 + 1
u'ABcdefghijklmno', # score = 15 + 2
u'AB1defghijklmn', # score = 14 + 2 + 1
u'AB12efghijklm', # score = 13 + 2 + 2
u'AB12+fghijkl', # score = 12 + 2 + 2 + 1
u'AB12+-ghijk', # score = 11 + 2 + 2 + 2
u'AB12+-*hij', # score = 10 + 2 + 2 + 3
u'AB12+-*hi/', # score = 10 + 2 + 2 + 3
u'AB12+-*hi3', # score = 10 + 2 + 2 + 3
u'AB12+-*hiC', # score = 10 + 2 + 2 + 3
u'abcbbbgbbjklmnopqr', # score = 17
]
validator = PasswordScoreValidator(min_classes=0)
for password in passwords:
try:
validator.validate(password)
except ValidationError as e:
self.assertEqual('too_little_score', e.code)
else:
self.fail(u'%s: no validation error was raised' % password)
def test_too_few_classes(self):
passwords = [
u'', # classes = 0
u'abcdefgh', # classes = 1
u'abcdef+-', # classes = 2
u'abcd12gh', # classes = 2
u'abcd12-+', # classes = 3
u'abCDefgh', # classes = 2
u'abCDef+-', # classes = 3
u'abCD12gh', # classes = 3
u'ABCD12-+', # classes = 3
]
validator = PasswordScoreValidator(min_score=0, min_classes=4)
for password in passwords:
try:
validator.validate(password)
except ValidationError as e:
self.assertEqual('too_few_classes', e.code)
else:
self.fail(u'%s: no validation error was raised' % password)
def test_valid(self):
passwords = [
u'Abcdefghijklmnopq',
u'ABcdefghijklmnop',
u'AB1defghijklmno',
u'AB12efghijklmn',
u'AB12+fghijklm',
u'AB12+-ghijkl',
u'AB12+-*hijk',
u'ab1defghijklmnopq',
u'abcd+fghijklmnopq',
u'AB1CDEFGHIJKLMNOPQ',
]
validator = PasswordScoreValidator()
for password in passwords:
try:
validator.validate(password)
except ValidationError as e:
self.fail(e)
class CustomWordlistPasswordValidatorTestCase(SimpleTestCase):
def test_invalid(self):
invalid_passwords = [
(u'passwort', [
u'Das Passwort darf nicht die Zeichenfolge \'passwort\' enthalten.',
]),
(u'abcdDaVefgh', [
u'Das Passwort darf nicht die Zeichenfolge \'dav\' enthalten.',
]),
(u'abcdsektIonefgh', [
u'Das Passwort darf nicht die Zeichenfolge \'sektion\' enthalten.',
]),
(u'alpen12verein34KArlsruhE berge', [
u'Das Passwort darf nicht die Zeichenfolge \'karlsruhe\' enthalten.',
u'Das Passwort darf nicht die Zeichenfolge \'berge\' enthalten.',
]),
(u'heinzel@alpenverein-karlsruhe.de', [
u'Das Passwort darf nicht die Zeichenfolge \'heinzel\' enthalten.',
u'Das Passwort darf nicht die Zeichenfolge \'alpenverein\' enthalten.',
u'Das Passwort darf nicht die Zeichenfolge \'karlsruhe\' enthalten.',
]),
]
validator = CustomWordlistPasswordValidator()
for password, expected_errors in invalid_passwords:
try:
validator.validate(password)
except ValidationError as e:
errors = e.messages
for expected_error in expected_errors:
self.assertIn(expected_error, errors)
for error in errors:
self.assertIn(error, expected_errors)
else:
self.fail(u'%s: no validation error was raised' % password)
def test_valid(self):
passwords = [
u'',
u'password',
u'münchen',
]
validator = CustomWordlistPasswordValidator()
for password in passwords:
try:
validator.validate(password)
except ValidationError as e:
self.fail(e)
class CharacterClassPasswordValidatorTestCase(SimpleTestCase):
def setUp(self):
super(CharacterClassPasswordValidatorTestCase, self).setUp()
self.validator = CharacterClassPasswordValidator()
def test_invalid(self):
invalid_passwords = [
(u'', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'A+-', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
]),
(u'1234567890*', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'34*/()', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
]),
(u'AA', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'CD0.,', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
]),
(u'EF56', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'8GH?!8', [
u'Das Passwort muss mindestens 2 Kleinbuchstaben enthalten.',
]),
(u'bbX', [
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'$cd%', [
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
]),
(u'ef90', [
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'1g=h3~', [
u'Das Passwort muss mindestens 2 Großbuchstaben enthalten.',
]),
(u'Gi&jH', [
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
(u'IkK:i;', [
u'Das Passwort muss mindestens 2 Ziffern enthalten.',
]),
(u'mKn4L8', [
u'Das Passwort muss mindestens 2 Sonderzeichen enthalten.',
]),
]
validator = self.validator
for password, expected_errors in invalid_passwords:
try:
validator.validate(password)
except ValidationError as e:
errors = e.messages
for expected_error in expected_errors:
self.assertIn(expected_error, errors)
for error in errors:
self.assertIn(error, expected_errors)
else:
self.fail(u'%s: no validation error was raised' % password)
def test_valid(self):
valid_passwords = [u'abCD12+-']
validator = self.validator
for password in valid_passwords:
try:
validator.validate(password)
except ValidationError as e:
self.fail(e)

View File

@@ -7,10 +7,10 @@ from django.test import TestCase
from django.utils.translation import ugettext from django.utils.translation import ugettext
from django.urls import reverse from django.urls import reverse
from ..forms import LoginForm, SetPasswordForm, ResetPasswordForm from ..forms import LoginForm, SetPasswordForm, CreateAndSendPasswordForm
TEST_USERNAME = 'root@localhost' TEST_USERNAME = 'root@localhost'
TEST_PASSWORD = u'me||ön 2' TEST_PASSWORD = u'me||ön 21ABll'
TEST_EMAIL = TEST_USERNAME TEST_EMAIL = TEST_USERNAME
@@ -27,7 +27,7 @@ class ViewsTestCase(TestCase):
cls.logout_url = reverse('dav_auth:logout') cls.logout_url = reverse('dav_auth:logout')
cls.logout_redirect_url = resolve_url(cls.app_settings.logout_redirect_url) cls.logout_redirect_url = resolve_url(cls.app_settings.logout_redirect_url)
cls.set_password_url = reverse('dav_auth:set_password') cls.set_password_url = reverse('dav_auth:set_password')
cls.reset_password_url = reverse('dav_auth:reset_password') cls.recreate_password_url = reverse('dav_auth:recreate_password')
# Some messages # Some messages
cls.wrong_credentials_message = ugettext(u'Benutzername oder Passwort falsch.') cls.wrong_credentials_message = ugettext(u'Benutzername oder Passwort falsch.')
@@ -179,23 +179,23 @@ class ViewsTestCase(TestCase):
self.assertFalse(self.client.login(username=username, password=password), 'Old password still valid') self.assertFalse(self.client.login(username=username, password=password), 'Old password still valid')
self.assertTrue(self.client.login(username=username, password=new_password), 'New password not valid') self.assertTrue(self.client.login(username=username, password=new_password), 'New password not valid')
def test_reset_password_integrated_unauth_get(self): def test_recreate_password_integrated_unauth_get(self):
response = self.client.get(self.reset_password_url) response = self.client.get(self.recreate_password_url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'dav_auth/forms/reset_password.html') self.assertTemplateUsed(response, 'dav_auth/forms/recreate_password.html')
self.assertIn('form', response.context) self.assertIn('form', response.context)
self.assertIsInstance(response.context['form'], ResetPasswordForm) self.assertIsInstance(response.context['form'], CreateAndSendPasswordForm)
field = response.context['form'].fields['username'] field = response.context['form'].fields['username']
self.assertTrue(field.required) self.assertTrue(field.required)
def test_reset_password_integrated_auth_get(self): def test_recreate_password_integrated_auth_get(self):
self.client.login(username=self.test_username, password=self.test_password) self.client.login(username=self.test_username, password=self.test_password)
response = self.client.get(self.reset_password_url) response = self.client.get(self.recreate_password_url)
self.assertRedirects(response, self.set_password_url) self.assertRedirects(response, self.set_password_url)
def test_reset_password_integrated_post(self): def test_recreate_password_integrated_post(self):
location = self.reset_password_url location = self.recreate_password_url
response = self.client.post(location, {'username': self.user.username}) response = self.client.post(location, {'username': self.user.username})
self.assertRedirects(response, self.login_url) self.assertRedirects(response, self.login_url)

View File

@@ -6,5 +6,5 @@ urlpatterns = [
url(r'^login$', views.LoginView.as_view(), name='login'), url(r'^login$', views.LoginView.as_view(), name='login'),
url(r'^logout$', views.LogoutView.as_view(), name='logout'), url(r'^logout$', views.LogoutView.as_view(), name='logout'),
url(r'^password$', views.SetPasswordView.as_view(), name='set_password'), url(r'^password$', views.SetPasswordView.as_view(), name='set_password'),
url(r'^password/reset$', views.ResetPasswordView.as_view(), name='reset_password'), url(r'^password/recreate$', views.CreateAndSendPasswordView.as_view(), name='recreate_password'),
] ]

200
dav_auth/validators.py Normal file
View File

@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
import re
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
class PasswordScoreValidator(object):
def _get_score(self, password, user=None):
score = 0
char_counters = {}
used_classes = []
credits = {
'lower': self.lcredit,
'upper': self.ucredit,
'digit': self.dcredit,
'other': self.ocredit,
}
for c in password:
if c not in char_counters:
char_counters[c] = 1
else:
char_counters[c] += 1
if (self.max_repeat > 0) and (char_counters[c] > self.max_repeat):
continue
score += 1
if c.isalpha() and c.islower():
char_class = 'lower'
elif c.isupper():
char_class = 'upper'
elif c.isdigit():
char_class = 'digit'
else:
char_class = 'other'
if char_class not in used_classes:
used_classes.append(char_class)
if credits[char_class] > 0:
score += 1
credits[char_class] -= 1
return score, len(used_classes)
def __init__(self, min_score=18, max_repeat=5, lcredit=0, ucredit=2, dcredit=2, ocredit=3, min_classes=2):
self.min_score = min_score
self.max_repeat = max_repeat
self.lcredit = lcredit
self.ucredit = ucredit
self.dcredit = dcredit
self.ocredit = ocredit
self.min_classes = min_classes
def validate(self, password, user=None):
score, used_classes = self._get_score(password, user=user)
if used_classes < self.min_classes:
raise ValidationError(_(u'Das Passwort muss Zeichen aus mindestens %(min_classes)d'
u' verschiedenen Arten von Zeichen bestehen'
u' (d.h. Kleinbuchstaben, Großbuchstaben, Ziffern und Sonderzeichen).'),
code='too_few_classes',
params={'min_classes': self.min_classes})
if score < self.min_score:
raise ValidationError(_(u'Dieses Passwort ist zu einfach. Benutze mehr Zeichen'
u' und gegebenenfalls auch Großbuchstaben, Ziffern und Sonderzeichen.'),
code='too_little_score')
return score
def get_help_text(self):
text = u'%s\n%s' % (
_(u'The password must get a minimum score of %d points.') % self.min_score,
_(u'For each character the score is increased by 1 point.'),
)
if self.lcredit > 0:
text += '\n'
text += _(u'The first %d lower characters increase the score by 1 point each.') % self.lcredit
if self.ucredit > 0:
text += '\n'
text += _(u'The first %d upper characters increase the score by 1 point each.') % self.ucredit
if self.dcredit > 0:
text += '\n'
text += _(u'The first %d digits increase the score by 1 point each.') % self.dcredit
if self.ocredit > 0:
text += '\n'
text += _(u'The first %d non alpha numeric characters'
u' increase the score by 1 point each.') % self.ocredit
if self.max_repeat > 0:
text += '\n'
text += _(u'If a particular character is used more than %d times,'
u' it will not increase the score anymore.') % self.max_repeat
if self.min_classes > 0:
text += '\n'
text += _(u'Also the password must contain characters from %d different character classes'
u' (i.e. lower, upper, digits, others).') % self.min_classes
return text
class CustomWordlistPasswordValidator(object):
context = 'the Sektion Karlsruhe'
words = (
u'dav',
u'berge',
u'sektion',
u'karlsruhe',
u'alpenverein',
u'heinzel',
u'passwort',
)
def validate(self, password, user=None):
errors = []
lower_pw = password.lower()
for word in self.words:
if word in lower_pw:
error = ValidationError(_(u'Das Passwort darf nicht die Zeichenfolge \'%(word)s\' enthalten.'),
code='forbidden_word',
params={'word': word})
errors.append(error)
if errors:
raise ValidationError(errors)
def get_help_text(self):
text = _(u'The password must not contain some specific words,'
u' that are common in context with %(context)s.') % {'context': self.context}
text += u'\n'
text += _(u'All words are matched case insensitive.')
return text
class CharacterClassPasswordValidator(object):
def _is_enough_lower(self, password):
lower = re.sub(r'[^a-z]', '', password)
if len(lower) < self.minimum_lower:
return False
return True
def _is_enough_upper(self, password):
upper = re.sub(r'[^A-Z]', '', password)
if len(upper) < self.minimum_upper:
return False
return True
def _is_enough_digits(self, password):
digits = re.sub(r'[^0-9]', '', password)
if len(digits) < self.minimum_digits:
return False
return True
def _is_enough_others(self, password):
others = re.sub(r'[a-zA-Z0-9]', '', password)
if len(others) < self.minimum_others:
return False
return True
def __init__(self, minimum_lower=2, minimum_upper=2, minimum_digits=2, minimum_others=2):
self.minimum_lower = minimum_lower
self.minimum_upper = minimum_upper
self.minimum_digits = minimum_digits
self.minimum_others = minimum_others
def validate(self, password, user=None):
errors = []
if not self._is_enough_lower(password):
error = ValidationError(_(u'Das Passwort muss mindestens %(min_lower)d Kleinbuchstaben enthalten.'),
code='too_few_lower_characters',
params={'min_lower': self.minimum_lower})
errors.append(error)
if not self._is_enough_upper(password):
error = ValidationError(_(u'Das Passwort muss mindestens %(min_upper)d Großbuchstaben enthalten.'),
code='too_few_upper_characters',
params={'min_upper': self.minimum_upper})
errors.append(error)
if not self._is_enough_digits(password):
error = ValidationError(_(u'Das Passwort muss mindestens %(min_digits)d Ziffern enthalten.'),
code='too_few_digits',
params={'min_digits': self.minimum_digits})
errors.append(error)
if not self._is_enough_others(password):
error = ValidationError(_(u'Das Passwort muss mindestens %(min_others)d'
u' Sonderzeichen enthalten.'),
code='too_few_other_characters',
params={'min_others': self.minimum_others})
errors.append(error)
if errors:
raise ValidationError(errors)
def get_help_text(self):
text = u'%s %s %s %s' % (
_('The password must contain at least %d characters from a-z.') % self.minimum_lower,
_('The password must contain at least %d characters from A-Z.') % self.minimum_upper,
_('The password must contain at least %d digits from 0-9.') % self.minimum_digits,
_('The password must contain at least %d non alpha numeric characters.') % self.minimum_others,
)
return text

View File

@@ -1,10 +1,14 @@
# -*- coding: utf-8 -*-
import logging import logging
from django.apps import apps from django.apps import apps
from django.core.exceptions import ValidationError
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import views as auth_views, get_user_model from django.contrib.auth import views as auth_views, get_user_model
from django.contrib.auth.password_validation import validate_password
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.urls import reverse_lazy, reverse from django.urls import reverse_lazy, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views import generic from django.views import generic
@@ -28,6 +32,18 @@ class LoginView(auth_views.LoginView):
def form_valid(self, form): def form_valid(self, form):
r = super(LoginView, self).form_valid(form) r = super(LoginView, self).form_valid(form)
messages.success(self.request, _(u'Benutzer angemeldet: %(username)s') % {'username': form.get_user()}) messages.success(self.request, _(u'Benutzer angemeldet: %(username)s') % {'username': form.get_user()})
try:
validate_password(form.cleaned_data['password'])
except ValidationError as e:
logger.warning(u'Weak password (%d): %s', self.request.user.pk, e)
message = u'<br />\n<p>\n'
message += u'Dein Passwort entspricht nicht mehr den aktuellen Passwortrichtlinien.<br />\n'
message += u'Bitte hilf uns die Daten deiner Teilnehmer zu schützen und ändere dein Passwort.<br />\n'
message += u'</p>\n'
message += u'<p>\n'
message += u'<a href="%(href)s">Passwort ändern</a>\n' % {'href': reverse('dav_auth:set_password')}
message += u'</p>\n<br />\n'
messages.warning(self.request, mark_safe(message))
return r return r
@@ -60,9 +76,9 @@ class SetPasswordView(auth_views.PasswordChangeView):
return r return r
class ResetPasswordView(generic.FormView): class CreateAndSendPasswordView(generic.FormView):
form_class = forms.ResetPasswordForm form_class = forms.CreateAndSendPasswordForm
template_name = 'dav_auth/forms/reset_password.html' template_name = 'dav_auth/forms/recreate_password.html'
success_url = reverse_lazy('dav_auth:login') success_url = reverse_lazy('dav_auth:login')
def form_valid(self, form): def form_valid(self, form):
@@ -70,19 +86,19 @@ class ResetPasswordView(generic.FormView):
user_model = get_user_model() user_model = get_user_model()
try: try:
user = user_model.objects.get(username=username) user = user_model.objects.get(username=username)
random_password = user_model.objects.make_random_password(length=12) random_password = user_model.objects.make_random_password(length=32)
user.set_password(random_password) user.set_password(random_password)
user.save() user.save()
email = emails.PasswordSetEmail(user, random_password) email = emails.PasswordSetEmail(user, random_password)
email.send() email.send()
messages.success(self.request, _(u'Neues Passwort versendet.')) messages.success(self.request, _(u'Neues Passwort versendet.'))
logger.info('Password reset for user \'%s\'', username) logger.info('Password recreated for user \'%s\'', username)
except user_model.DoesNotExist: except user_model.DoesNotExist:
logger.warning('Password reset for unknown user \'%s\'', username) logger.warning('Password recreated for unknown user \'%s\'', username)
return super(ResetPasswordView, self).form_valid(form) return super(CreateAndSendPasswordView, self).form_valid(form)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if request.user.is_authenticated: if request.user.is_authenticated:
return HttpResponseRedirect(reverse('dav_auth:set_password')) return HttpResponseRedirect(reverse('dav_auth:set_password'))
return super(ResetPasswordView, self).get(request, *args, **kwargs) return super(CreateAndSendPasswordView, self).get(request, *args, **kwargs)

View File

@@ -52,6 +52,30 @@ DATABASES['default'] = {
'NAME': os.path.join(BASE_VAR_DIR, 'db', 'devel.sqlite3'), 'NAME': os.path.join(BASE_VAR_DIR, 'db', 'devel.sqlite3'),
} }
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {
'min_length': 12,
},
},
{
'NAME': 'dav_auth.validators.PasswordScoreValidator',
},
{
'NAME': 'dav_auth.validators.CustomWordlistPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
STATIC_ROOT = os.path.join(BASE_VAR_DIR, 'www', 'static') STATIC_ROOT = os.path.join(BASE_VAR_DIR, 'www', 'static')
LANGUAGE_CODE = 'de' LANGUAGE_CODE = 'de'

View File

@@ -14,7 +14,7 @@ from dav_auth.tests.generic import SeleniumAuthMixin
from .generic import RoleMixin from .generic import RoleMixin
TEST_TRAINER_EMAIL = 'trainer@localhost' TEST_TRAINER_EMAIL = 'trainer@localhost'
TEST_PASSWORD = u'me||ön 2' TEST_PASSWORD = u'me||ön 21ABll'
TEST_EVENT_DATA_S = { TEST_EVENT_DATA_S = {
'mode': 'training', 'mode': 'training',
'sport': 'S', 'sport': 'S',