diff --git a/.gitignore b/.gitignore index 8c5e7b16..63aeb943 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ docs/_build *.pyc .tox build +.idea \ No newline at end of file diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 88d56fed..159a7b64 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -32,6 +32,8 @@ What you get * Password reset links that expire in two days (configurable). +* Optionally invoke a captcha in the password reset page if django-simple-captcha (0.5.1 or higher) is installed and configured. + What you can do --------------- diff --git a/docs/views.rst b/docs/views.rst index a873dbc3..6ae4db22 100644 --- a/docs/views.rst +++ b/docs/views.rst @@ -43,6 +43,10 @@ Attributes * ``email_template_name``: the template to use for sending the reset link by email. Default: ``password_reset/recovery_email.txt``. +* ``email_html_template_name``: the HTML template to use for sending the reset link by + email. The content of this template will be sent as the text/html content type alternative. + Default: ``password_reset/recovery_email.html``. + * ``email_subject_template_name``: the template to use for generating the email subject. Defaults to ``password_reset/recovery_email_subject.txt``. diff --git a/password_reset/__init__.py b/password_reset/__init__.py index 7e49527e..1f356cc5 100644 --- a/password_reset/__init__.py +++ b/password_reset/__init__.py @@ -1 +1 @@ -__version__ = '1.0' +__version__ = '1.0.0' diff --git a/password_reset/compat/__init__.py b/password_reset/compat/__init__.py new file mode 100644 index 00000000..d2f743e8 --- /dev/null +++ b/password_reset/compat/__init__.py @@ -0,0 +1,34 @@ +# coding=utf-8 + +def get_user_model(): + """ + Returns the User model that is active in this project. + """ + try: + from django.contrib.auth import get_user_model + User = get_user_model() + except ImportError: + from django.contrib.auth.models import User + return User + + +def get_username(user): + """ + Returns the name of the username field + :param user: User object instance + :return: str + """ + username_field = getattr(user, 'USERNAME_FIELD', 'username') + return getattr(user, username_field) + + +def get_current_site(*args, **kwargs): + """ + Checks if contrib.sites is installed and returns either the current + ``Site`` object or a ``RequestSite`` object based on the request. + """ + try: + from django.contrib.sites.shortcuts import get_current_site as current_site + except ImportError: + from django.contrib.sites.models import get_current_site as current_site + return current_site(*args, **kwargs) diff --git a/password_reset/compat/urls.py b/password_reset/compat/urls.py new file mode 100644 index 00000000..54c1ce6c --- /dev/null +++ b/password_reset/compat/urls.py @@ -0,0 +1,6 @@ +# coding=utf-8 + +try: + from django.urls import reverse, reverse_lazy, NoReverseMatch +except ImportError: + from django.core.urlresolvers import reverse, reverse_lazy, NoReverseMatch \ No newline at end of file diff --git a/password_reset/forms.py b/password_reset/forms.py index 8dc0ad1d..02367f04 100644 --- a/password_reset/forms.py +++ b/password_reset/forms.py @@ -1,17 +1,45 @@ from django import forms -from django.contrib.auth import get_user_model +from django.core.exceptions import ImproperlyConfigured from django.core.validators import validate_email from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.conf import settings +from .compat import get_user_model +error_messages = { + 'not_found': _("Sorry, this user doesn't exist."), + 'password_mismatch': _("The two passwords didn't match."), +} + +try: + # must be installed before. + if 'captcha' not in settings.INSTALLED_APPS: + raise ImproperlyConfigured("captcha is not installed.") + + # uses django-simple-captcha + from captcha.fields import CaptchaField + + + class CaptchaForm(forms.Form): + captcha = CaptchaField(label=_('Captcha')) # Optional Captcha + + def order_fields(self, field_order): + """https://docs.djangoproject.com/en/1.9/ref/forms/api/#django.forms.Form.order_fields""" + # Put the captcha at the bottom of the form + field_name = "captcha" + if field_order is None: + field_order = self.fields.keys() + field_order.pop(field_order.index(field_name)) + field_order.append(field_name) + return super(CaptchaForm, self).order_fields(field_order) -class PasswordRecoveryForm(forms.Form): - username_or_email = forms.CharField() - error_messages = { - 'not_found': _("Sorry, this user doesn't exist."), - } +except (ImproperlyConfigured, ImportError): + CaptchaForm = forms.Form + + +class PasswordRecoveryForm(CaptchaForm): + username_or_email = forms.CharField() def __init__(self, *args, **kwargs): self.case_sensitive = kwargs.pop('case_sensitive', True) @@ -52,17 +80,23 @@ def clean_username_or_email(self): if recovery_only_active_users and not user_is_active: raise forms.ValidationError(_("Sorry, inactive users can't " - "recover their password.")) + "recover their password.")) return username + def get_user(self, *args, **kwargs): + """ Method used to customize how to get the user into subclasses. + :rtype: User object + """ + return get_user_model()._default_manager.get(*args, **kwargs) + def get_user_by_username(self, username): key = 'username__%sexact' % ('' if self.case_sensitive else 'i') User = get_user_model() try: - user = User._default_manager.get(**{key: username}) + user = self.get_user(**{key: username}) except User.DoesNotExist: - raise forms.ValidationError(self.error_messages['not_found'], + raise forms.ValidationError(error_messages['not_found'], code='not_found') return user @@ -71,9 +105,9 @@ def get_user_by_email(self, email): key = 'email__%sexact' % ('' if self.case_sensitive else 'i') User = get_user_model() try: - user = User._default_manager.get(**{key: email}) + user = self.get_user(**{key: email}) except User.DoesNotExist: - raise forms.ValidationError(self.error_messages['not_found'], + raise forms.ValidationError(error_messages['not_found'], code='not_found') return user @@ -81,14 +115,15 @@ def get_user_by_both(self, username): key = '__%sexact' key = key % '' if self.case_sensitive else key % 'i' - def f(field): + def f(field): # to satisfy lint in Travis auto build on Github return Q(**{field + key: username}) + filters = f('username') | f('email') User = get_user_model() try: - user = User._default_manager.get(filters) + user = self.get_user(filters) except User.DoesNotExist: - raise forms.ValidationError(self.error_messages['not_found'], + raise forms.ValidationError(error_messages['not_found'], code='not_found') except User.MultipleObjectsReturned: raise forms.ValidationError(_("Unable to find user.")) @@ -106,10 +141,6 @@ class PasswordResetForm(forms.Form): widget=forms.PasswordInput, ) - error_messages = { - 'password_mismatch': _("The two passwords didn't match."), - } - def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') super(PasswordResetForm, self).__init__(*args, **kwargs) @@ -119,7 +150,7 @@ def clean_password2(self): password2 = self.cleaned_data['password2'] if not password1 == password2: raise forms.ValidationError( - self.error_messages['password_mismatch'], + error_messages['password_mismatch'], code='password_mismatch') return password2 diff --git a/password_reset/locale/es/LC_MESSAGES/django.po b/password_reset/locale/es/LC_MESSAGES/django.po index a5423683..df7c8e2d 100644 --- a/password_reset/locale/es/LC_MESSAGES/django.po +++ b/password_reset/locale/es/LC_MESSAGES/django.po @@ -127,5 +127,5 @@ msgid "" "An email was sent to %(email)s %(ago)s ago. Use the link in " "it to set a new password." msgstr "" -"Un email ha sido enviado a %(email)s hace %(ago)s minutos. " +"Un email ha sido enviado a %(email)s hace %(ago)s. " "Use el enlace recibido para recuperar su clave" diff --git a/password_reset/locale/zh b/password_reset/locale/zh new file mode 100644 index 00000000..80d1c227 --- /dev/null +++ b/password_reset/locale/zh @@ -0,0 +1 @@ +zh-hans \ No newline at end of file diff --git a/password_reset/locale/zh-hans/LC_MESSAGES/django.mo b/password_reset/locale/zh-hans/LC_MESSAGES/django.mo new file mode 100644 index 00000000..eb97a384 Binary files /dev/null and b/password_reset/locale/zh-hans/LC_MESSAGES/django.mo differ diff --git a/password_reset/locale/zh/LC_MESSAGES/django.po b/password_reset/locale/zh-hans/LC_MESSAGES/django.po similarity index 60% rename from password_reset/locale/zh/LC_MESSAGES/django.po rename to password_reset/locale/zh-hans/LC_MESSAGES/django.po index 12fa1c0a..6fdbb8df 100644 --- a/password_reset/locale/zh/LC_MESSAGES/django.po +++ b/password_reset/locale/zh-hans/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2014-01-23 10:01+0800\n" +"POT-Creation-Date: 2016-02-05 16:48+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,35 +17,43 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: forms.py:26 +#: forms.py:14 +msgid "Captcha" +msgstr "认证码" + +#: forms.py:17 +msgid "Sorry, this user doesn't exist." +msgstr "抱歉,用户名不存在." + +#: forms.py:34 msgid "Username" msgstr "用户名" -#: forms.py:27 +#: forms.py:35 msgid "Email" msgstr "邮箱" -#: forms.py:28 +#: forms.py:36 msgid "Username or Email" msgstr "用户名或者邮箱" -#: forms.py:48 forms.py:58 forms.py:70 -msgid "Sorry, this user doesn't exist." -msgstr "抱歉,用户名不存在." +#: forms.py:58 +msgid "Sorry, inactive users can't recover their password." +msgstr "非常抱歉,非活跃用户无法重置密码。" -#: forms.py:72 +#: forms.py:96 msgid "Unable to find user." msgstr "找不到指定用户" -#: forms.py:78 +#: forms.py:103 msgid "New password" msgstr "新密码" -#: forms.py:82 +#: forms.py:107 msgid "New password (confirm)" -msgstr "新密码(确认)" +msgstr "新密码(确认)" -#: forms.py:94 +#: forms.py:112 msgid "The two passwords didn't match." msgstr "两次输入的密码不一致" @@ -57,19 +65,19 @@ msgstr "设置新密码" msgid "" "Your password has successfully been reset. You can use it right now on the " "login page." -msgstr "您的密码已经重设成功,现在可以登录了." +msgstr "您的密码已经重设成功,现在可以登录了。" #: templates/password_reset/recovery_email.txt:1 #, python-format msgid "Dear %(username)s," -msgstr "您好 %(username)s," +msgstr "%(username)s,您好:" #: templates/password_reset/recovery_email.txt:3 #, python-format msgid "" "You -- or someone pretending to be you -- has requested a password reset on " "%(domain)s." -msgstr "您或者其它的人在 %(domain)s 申请了密码重置." +msgstr "您或某人在 %(domain)s 申请了密码重置。" #: templates/password_reset/recovery_email.txt:5 msgid "You can set your new password by following this link:" @@ -79,18 +87,30 @@ msgstr "您可以通过以下链接重置密码:" msgid "" "If you don't want to reset your password, simply ignore this email and it " "will stay unchanged." -msgstr "如果您不想重设密码,可以忽略此邮箱." +msgstr "若不想重设密码,可以忽略此邮件。" + +#: templates/password_reset/recovery_email.txt:11 +msgid "Yours Truly" +msgstr "感谢您的惠顾" + +#: templates/password_reset/recovery_email.txt:12 +msgid "Administrator" +msgstr "管理者" #: templates/password_reset/recovery_email_subject.txt:1 #, python-format msgid "Password recovery on %(domain)s" -msgstr "在 %(domain)s 上重设密码" +msgstr "重设 %(domain)s 密码" #: templates/password_reset/recovery_form.html:5 msgid "Password recovery" msgstr "重新设置密码" -#: templates/password_reset/recovery_form.html:11 +#: templates/password_reset/recovery_form.html:8 +msgid "Recover Password" +msgstr "重设密码" + +#: templates/password_reset/recovery_form.html:60 msgid "Recover my password" msgstr "重设密码" @@ -99,7 +119,8 @@ msgstr "重设密码" msgid "" "Sorry, this password reset link is invalid. You can still request a new one." -msgstr "抱歉,链接已经失效,您可以在 请求一个新链接." +msgstr "" +"抱歉,链接已经失效,您可以在 请求一个新链接." #: templates/password_reset/reset.html:7 #, python-format @@ -114,9 +135,26 @@ msgstr "设置新密码" msgid "Password recovery sent" msgstr "发送重设密码" -#: templates/password_reset/reset_sent.html:7 +#: templates/password_reset/reset_sent.html:8 #, python-format msgid "" "An email was sent to %(email)s %(ago)s ago. Use the link in " "it to set a new password." -msgstr "已经在%(ago)s以前向您的邮箱 %(email)s发送了邮件,根据邮件提示重新设置您的新密码." +msgstr "" +"已经在%(ago)s以前向您的邮箱 %(email)s 发送了邮件,请根据邮件" +"提示重新设置您的新密码." + +#: templates/password_reset/reset_sent.html:11 +#: templates/password_reset/reset_sent_failed.html:9 +msgid "Return to Main Page" +msgstr "回主页" + +#: templates/password_reset/reset_sent_failed.html:4 +msgid "Password recovery NOT successful" +msgstr "发送重设密码" + +#: templates/password_reset/reset_sent_failed.html:7 +msgid "" +"The email for this registered user is invalid, password reset information " +"cannot be sent. Please contact our administrator for assistance." +msgstr "此用户电邮地址无法接收密码重置信息。欢迎联系我们的客服部门。" diff --git a/password_reset/locale/zh/LC_MESSAGES/django.mo b/password_reset/locale/zh/LC_MESSAGES/django.mo deleted file mode 100644 index f1f11c35..00000000 Binary files a/password_reset/locale/zh/LC_MESSAGES/django.mo and /dev/null differ diff --git a/password_reset/locale/zh_Hans b/password_reset/locale/zh_Hans new file mode 120000 index 00000000..80d1c227 --- /dev/null +++ b/password_reset/locale/zh_Hans @@ -0,0 +1 @@ +zh-hans \ No newline at end of file diff --git a/password_reset/models.py b/password_reset/models.py new file mode 100644 index 00000000..e69de29b diff --git a/password_reset/signals.py b/password_reset/signals.py index 6bf3823f..f3bd8381 100644 --- a/password_reset/signals.py +++ b/password_reset/signals.py @@ -1,6 +1,4 @@ from django.dispatch import Signal # signal sent when users successfully recover their passwords -user_recovers_password = Signal( - providing_args=['user', 'request'] -) +user_recovers_password = Signal() diff --git a/password_reset/templates/password_reset/base_email.html b/password_reset/templates/password_reset/base_email.html new file mode 100644 index 00000000..d7b8464a --- /dev/null +++ b/password_reset/templates/password_reset/base_email.html @@ -0,0 +1,184 @@ +{% load i18n %} + + + + + + {% block title %}{% endblock title %} + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ {% block content %} + {% endblock content %} +
+
+ + + diff --git a/password_reset/templates/password_reset/recovery_email.html b/password_reset/templates/password_reset/recovery_email.html new file mode 100644 index 00000000..1794071a --- /dev/null +++ b/password_reset/templates/password_reset/recovery_email.html @@ -0,0 +1,9 @@ +{% extends "password_reset/base_email.html" %} +{% load i18n %} + +{% block content %} +

{% blocktrans %}Dear {{ username }},{% endblocktrans %}

+

{% blocktrans with domain=site.domain %}You -- or someone pretending to be you -- has requested a password reset on {{ domain }}.{% endblocktrans %}

+

{% trans "You can set your new password by following this link:" %} http{% if secure %}s{% endif %}://{{ site.domain }}{% url "password_reset_reset" token %}

+

{% trans "If you don't want to reset your password, simply ignore this email and it will stay unchanged." %}

+{% endblock content %} \ No newline at end of file diff --git a/password_reset/templates/password_reset/recovery_email.txt b/password_reset/templates/password_reset/recovery_email.txt index 9c4af9cc..70b1af2a 100644 --- a/password_reset/templates/password_reset/recovery_email.txt +++ b/password_reset/templates/password_reset/recovery_email.txt @@ -7,3 +7,7 @@ http{% if secure %}s{% endif %}://{{ site.domain }}{% url "password_reset_reset" token %} {% trans "If you don't want to reset your password, simply ignore this email and it will stay unchanged." %} + +{% trans "Yours Truly" %}, +{% trans "Administrator" %} +{{ site.domain }} diff --git a/password_reset/templates/password_reset/recovery_form.html b/password_reset/templates/password_reset/recovery_form.html index e0aff0ca..4e152c0f 100644 --- a/password_reset/templates/password_reset/recovery_form.html +++ b/password_reset/templates/password_reset/recovery_form.html @@ -4,9 +4,42 @@ {% block title %}{% trans "Password recovery" %}{% endblock %} {% block content %} -
- {% csrf_token %} - {{ form.as_p }} -

-
-{% endblock %} +

{% trans "Recover Password" %}

+
+{% csrf_token %} +{% for field in form %} +{% if field.name != "captcha" %} +
+ {{ field.label_tag }} + +
+{% else %} +

+

+ + + +   {{ field }} +
+{% endif %} +
+ {{ field.errors }} +
+{% endfor %} + +
+
+ +
+
+ +
+{% endblock content %} diff --git a/password_reset/templates/password_reset/reset_sent.html b/password_reset/templates/password_reset/reset_sent.html index bc56b7c2..f5362f78 100644 --- a/password_reset/templates/password_reset/reset_sent.html +++ b/password_reset/templates/password_reset/reset_sent.html @@ -4,5 +4,11 @@ {% block title %}{% trans "Password recovery sent" %}{% endblock %} {% block content %} + + +{% trans "Return to Main Page" %} + + {% endblock %} diff --git a/password_reset/templates/password_reset/reset_sent_failed.html b/password_reset/templates/password_reset/reset_sent_failed.html new file mode 100644 index 00000000..77f3f6c3 --- /dev/null +++ b/password_reset/templates/password_reset/reset_sent_failed.html @@ -0,0 +1,11 @@ +{% extends "password_reset/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Password recovery NOT successful" %}{% endblock %} + +{% block content %} +

{% trans "The email for this registered user is invalid, password reset information cannot be sent. Please contact our administrator for assistance." %}

+ +{% trans "Return to Main Page" %} + +{% endblock %} diff --git a/password_reset/tests/settings.py b/password_reset/tests/settings.py index 42b01387..c68658b1 100644 --- a/password_reset/tests/settings.py +++ b/password_reset/tests/settings.py @@ -9,12 +9,12 @@ }, } -INSTALLED_APPS = ( +INSTALLED_APPS = [ 'django.contrib.auth', 'django.contrib.contenttypes', 'password_reset', 'password_reset.tests', -) +] MIDDLEWARE = [] diff --git a/password_reset/tests/tests.py b/password_reset/tests/tests.py index cad8ddaa..7af52569 100644 --- a/password_reset/tests/tests.py +++ b/password_reset/tests/tests.py @@ -1,15 +1,26 @@ -from unittest import SkipTest - -from django.contrib.auth import get_user_model +import django from django.core import mail -from django.core.urlresolvers import reverse +from password_reset.compat.urls import reverse from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone from django.utils.six import with_metaclass +try: + from django.utils.unittest import SkipTest +except ImportError: + from unittest import SkipTest -from ..forms import PasswordRecoveryForm, PasswordResetForm +from ..forms import PasswordRecoveryForm, PasswordResetForm, error_messages from .models import CustomUser, ExtensionUser +from ..compat import get_user_model + +if django.VERSION < (1, 6): + COLON_SUFFIX = '' # Django 1.5 or lower do NOT auto add colon suffix +else: + COLON_SUFFIX = ':' # Django 1.6 or higher auto add colon suffix + +# Test manually via ./manage.py test --settings password_reset.tests.settings +# password_test.tests class CustomUserVariants(type): @@ -50,7 +61,7 @@ def test_username_input(self): form = PasswordRecoveryForm(data={'username_or_email': 'inexisting'}) self.assertFalse(form.is_valid()) self.assertEqual(form.errors['username_or_email'], - ["Sorry, this user doesn't exist."]) + [error_messages['not_found']]) create_user() @@ -122,7 +133,7 @@ def test_form_custom_search(self): }, search_fields=['email']) self.assertFalse(form.is_valid()) self.assertEqual(form.errors['username_or_email'], - ["Sorry, this user doesn't exist."]) + [error_messages['not_found']]) user = create_user() @@ -131,7 +142,7 @@ def test_form_custom_search(self): }, search_fields=['email']) self.assertFalse(form.is_valid()) self.assertEqual(form.errors['username_or_email'], - ["Sorry, this user doesn't exist."]) + [error_messages['not_found']]) # Search by actual email works form = PasswordRecoveryForm(data={ @@ -151,7 +162,7 @@ def test_form_custom_search(self): }, search_fields=['username']) self.assertFalse(form.is_valid()) self.assertEqual(form.errors['username_or_email'], - ["Sorry, this user doesn't exist."]) + [error_messages['not_found']]) form = PasswordRecoveryForm(data={ 'username_or_email': 'username', @@ -270,7 +281,8 @@ def test_email_recover(self): url = reverse('email_recover') response = self.client.get(url) self.assertNotContains(response, "Username or Email") - self.assertContains(response, "Email:") + + self.assertContains(response, "Email%s" % COLON_SUFFIX) response = self.client.post(url, {'username_or_email': 'foo'}) try: @@ -286,6 +298,8 @@ def test_email_recover(self): url, {'username_or_email': 'bar@example.com'}, follow=True, ) self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox[0].alternatives), 1) + self.assertEqual(mail.outbox[0].alternatives[0][1], 'text/html') self.assertEqual(len(response.redirect_chain), 1) self.assertContains(response, 'bar@example.com') @@ -297,7 +311,7 @@ def test_username_recover(self): response = self.client.get(url) self.assertNotContains(response, "Username or Email") - self.assertContains(response, "Username:") + self.assertContains(response, "Username%s" % COLON_SUFFIX) response = self.client.post(url, {'username_or_email': 'bar@example.com'}) diff --git a/password_reset/tests/urls.py b/password_reset/tests/urls.py index 25832f3d..edc07682 100644 --- a/password_reset/tests/urls.py +++ b/password_reset/tests/urls.py @@ -1,8 +1,13 @@ -from django.conf.urls import url +from django.conf.urls import url, include from ..urls import urlpatterns from . import views +try: + import captcha as captcha_installed +except: + captcha_installed = None + urlpatterns += [ url(r'^email_recover/$', views.email_recover, name='email_recover'), url(r'^username_recover/$', views.username_recover, @@ -10,3 +15,8 @@ url(r'^insensitive_recover/$', views.insensitive_recover, name='insensitive_recover'), ] + +if captcha_installed: + urlpatterns += [ + url(r'^captcha/', include('captcha.urls')), + ] diff --git a/password_reset/urls.py b/password_reset/urls.py index eedad198..7b4817d9 100644 --- a/password_reset/urls.py +++ b/password_reset/urls.py @@ -6,6 +6,8 @@ urlpatterns = [ url(r'^recover/(?P.+)/$', views.recover_done, name='password_reset_sent'), + url(r'^recover/(?P.+)/$', views.recover_failed, + name='password_reset_sent_failed'), url(r'^recover/$', views.recover, name='password_reset_recover'), url(r'^reset/done/$', views.reset_done, name='password_reset_done'), url(r'^reset/(?P[\w:-]+)/$', views.reset, diff --git a/password_reset/views.py b/password_reset/views.py index 6f172a40..16726eda 100644 --- a/password_reset/views.py +++ b/password_reset/views.py @@ -1,20 +1,19 @@ import datetime from django.conf import settings -from django.contrib.auth import get_user_model -from django.contrib.sites.shortcuts import get_current_site from django.core import signing -from django.core.mail import send_mail -from django.core.urlresolvers import reverse, reverse_lazy -from django.http import Http404 +from django.core.mail import EmailMultiAlternatives +from password_reset.compat.urls import reverse, reverse_lazy from django.shortcuts import get_object_or_404, redirect +from django.http import Http404 from django.template import loader from django.utils import timezone from django.utils.decorators import method_decorator from django.views import generic -from django.views.decorators.debug import sensitive_post_parameters - +from django.contrib.auth.hashers import get_hasher +from django.views.decorators.debug import sensitive_post_parameters +from .compat import get_current_site, get_user_model, get_username from .forms import PasswordRecoveryForm, PasswordResetForm from .signals import user_recovers_password @@ -23,6 +22,9 @@ class SaltMixin(object): salt = 'password_recovery' url_salt = 'password_recovery_url' + def hash_password(self, psw): + return get_hasher().encode(psw, self.salt) + def loads_with_timestamp(value, salt): """Returns the unsigned value along with its timestamp, the time when it @@ -36,7 +38,7 @@ def loads_with_timestamp(value, salt): class RecoverDone(SaltMixin, generic.TemplateView): - template_name = 'password_reset/reset_sent.html' + template_name = "password_reset/reset_sent.html" def get_context_data(self, **kwargs): ctx = super(RecoverDone, self).get_context_data(**kwargs) @@ -52,17 +54,38 @@ def get_context_data(self, **kwargs): recover_done = RecoverDone.as_view() +class RecoverFailed(SaltMixin, generic.TemplateView): + template_name = 'password_reset/reset_sent_failed.html' + + def get_context_data(self, **kwargs): + ctx = super(RecoverFailed, self).get_context_data(**kwargs) + try: + ctx['timestamp'], ctx['email'] = loads_with_timestamp( + self.kwargs['signature'], salt=self.url_salt, + ) + except signing.BadSignature: + raise Http404 + return ctx +recover_failed = RecoverFailed.as_view() + + class Recover(SaltMixin, generic.FormView): case_sensitive = True form_class = PasswordRecoveryForm template_name = 'password_reset/recovery_form.html' success_url_name = 'password_reset_sent' + failure_url_name = 'password_reset_sent_failed' email_template_name = 'password_reset/recovery_email.txt' + email_html_template_name = 'password_reset/recovery_email.html' email_subject_template_name = 'password_reset/recovery_email_subject.txt' search_fields = ['username', 'email'] + sent_success = 0 def get_success_url(self): - return reverse(self.success_url_name, args=[self.mail_signature]) + if self.sent_success: + return reverse(self.success_url_name, args=[self.mail_signature]) + else: + return reverse(self.failure_url_name, args=[self.mail_signature]) def get_context_data(self, **kwargs): kwargs['url'] = self.request.get_full_path() @@ -70,6 +93,10 @@ def get_context_data(self, **kwargs): def get_form_kwargs(self): kwargs = super(Recover, self).get_form_kwargs() + + if hasattr(settings, 'PASSWORD_RESET_CASE_SENSITIVE'): + self.case_sensitive = settings.PASSWORD_RESET_CASE_SENSITIVE + kwargs.update({ 'case_sensitive': self.case_sensitive, 'search_fields': self.search_fields, @@ -83,23 +110,37 @@ def send_notification(self): context = { 'site': self.get_site(), 'user': self.user, - 'username': self.user.get_username(), - 'token': signing.dumps(self.user.pk, salt=self.salt), + 'username': get_username(self.user), + 'token': signing.dumps( + { + 'pk': self.user.pk, + 'psw': self.hash_password( + self.user.password + ) + }, + salt=self.salt), 'secure': self.request.is_secure(), } - body = loader.render_to_string(self.email_template_name, - context).strip() - subject = loader.render_to_string(self.email_subject_template_name, - context).strip() - send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, - [self.user.email]) + text_content = loader.render_to_string( + self.email_template_name, context).strip() + html_content = loader.render_to_string( + self.email_html_template_name, context).strip() + subject = loader.render_to_string( + self.email_subject_template_name, context).strip() + + msg = EmailMultiAlternatives( + subject, text_content, + settings.DEFAULT_FROM_EMAIL, [self.user.email, ]) + msg.attach_alternative(html_content, 'text/html') + result = msg.send(fail_silently=True) + return result def form_valid(self, form): self.user = form.cleaned_data['user'] - self.send_notification() + self.sent_success = self.send_notification() if ( - len(self.search_fields) == 1 and - self.search_fields[0] == 'username' + len(self.search_fields) == 1 and + self.search_fields[0] == 'username' ): # if we only search by username, don't disclose the user email # since it may now be public information. @@ -115,7 +156,7 @@ def form_valid(self, form): class Reset(SaltMixin, generic.FormView): form_class = PasswordResetForm - token_expires = None + token_expires = 3600 * 48 # Two days template_name = 'password_reset/reset.html' success_url = reverse_lazy('password_reset_done') @@ -134,13 +175,25 @@ def dispatch(self, request, *args, **kwargs): self.user = None try: - pk = signing.loads(kwargs['token'], - max_age=self.get_token_expires(), - salt=self.salt) + unsigned_pk_hash = signing.loads(kwargs['token'], + max_age=self.token_expires, + salt=self.salt) except signing.BadSignature: return self.invalid() + try: + pk = unsigned_pk_hash['pk'] + password = unsigned_pk_hash['psw'] + except KeyError: + return self.invalid() + self.user = get_object_or_404(get_user_model(), pk=pk) + + # Ensure the hashed password is same to prevent link to be reused + # TODO: this is assuming the password is changed + if password != self.hash_password(self.user.password): + return self.invalid() + return super(Reset, self).dispatch(request, *args, **kwargs) def invalid(self): @@ -155,7 +208,7 @@ def get_context_data(self, **kwargs): ctx = super(Reset, self).get_context_data(**kwargs) if 'invalid' not in ctx: ctx.update({ - 'username': self.user.get_username(), + 'username': get_username(self.user), 'token': self.kwargs['token'], }) return ctx diff --git a/setup.py b/setup.py index 3b5d5420..46693b18 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# coding=utf-8 try: from setuptools import setup except ImportError: @@ -19,16 +19,14 @@ description='Class-based views for password reset.', long_description=open('README.rst').read(), install_requires=[ - 'Django>=1.8', + 'Django>=1.4', + # It will be installed, but will only be used if properly configured. + 'django-simple-captcha>=0.5.1', ], classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Web Environment', 'Framework :: Django', - 'Framework :: Django :: 1.8', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', - 'Framework :: Django :: 1.11', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Natural Language :: English',