diff --git a/src/pretix/base/forms/auth.py b/src/pretix/base/forms/auth.py index 724e1ddaf9..f3c96dfcf7 100644 --- a/src/pretix/base/forms/auth.py +++ b/src/pretix/base/forms/auth.py @@ -140,8 +140,10 @@ class PasswordForgotForm(forms.Form): label=_('E-mail'), ) - def __init__(self, event, *args, **kwargs): - self.event = event + def __init__(self, *args, **kwargs): + if 'event' in kwargs: + # Backwards compatibility + del kwargs['event'] super().__init__(*args, **kwargs) def clean_email(self): diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 8893ebff38..b5337df535 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -42,26 +42,27 @@ def mail(email: str, subject: str, template: str, context: dict=None, event: Eve sender = event.settings.get('mail_from') if event else settings.MAIL_FROM subject = str(subject) - prefix = event.settings.get('mail_prefix') - if prefix: - subject = "[%s] %s" % (prefix, subject) + if event: + prefix = event.settings.get('mail_prefix') + if prefix: + subject = "[%s] %s" % (prefix, subject) - body += "\r\n\r\n----\r\n" - body += _( - "You are receiving this e-mail because you placed an order for %s." % event.name - ) - body += "\r\n" - body += _( - "You can view all of your orders at the following URL:" - ) - body += "\r\n" - body += build_absolute_uri( - 'presale:event.orders', kwargs={ - 'event': event.slug, - 'organizer': event.organizer.slug - } - ) - body += "\r\n" + body += "\r\n\r\n----\r\n" + body += _( + "You are receiving this e-mail because you placed an order for %s." % event.name + ) + body += "\r\n" + body += _( + "You can view all of your orders at the following URL:" + ) + body += "\r\n" + body += build_absolute_uri( + 'presale:event.orders', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug + } + ) + body += "\r\n" try: return mail_send([email], subject, body, sender) finally: diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index d7b36b121f..1e80426457 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -20,7 +20,9 @@ class PermissionMiddleware: EXCEPTIONS = ( "auth.login", - "auth.register" + "auth.register", + "auth.forgot", + "auth.forgot.recover" ) def process_request(self, request): diff --git a/src/pretix/control/templates/pretixcontrol/auth/base.html b/src/pretix/control/templates/pretixcontrol/auth/base.html index b47e10833d..0647fc6233 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/base.html +++ b/src/pretix/control/templates/pretixcontrol/auth/base.html @@ -3,24 +3,34 @@ {% load staticfiles %} - - {{ settings.PRETIX_INSTANCE_NAME }} - {% compress css %} - - {% endcompress %} - - - -
- {% block content %} - {% endblock %} - -
- + + {{ settings.PRETIX_INSTANCE_NAME }} + {% compress css %} + + {% endcompress %} + + + +
+ + + + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% block content %} + {% endblock %} + +
+ diff --git a/src/pretix/control/templates/pretixcontrol/auth/forgot.html b/src/pretix/control/templates/pretixcontrol/auth/forgot.html new file mode 100644 index 0000000000..3140a8497c --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/auth/forgot.html @@ -0,0 +1,18 @@ +{% extends "pretixcontrol/auth/base.html" %} +{% load bootstrap3 %} +{% load staticfiles %} +{% load i18n %} +{% block content %} +
+

{% trans "Password recovery" %}

+ {% csrf_token %} + {% bootstrap_form_errors form type='all' layout='inline' %} + {% bootstrap_field form.email %} +
+ + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/auth/login.html b/src/pretix/control/templates/pretixcontrol/auth/login.html index bddd3f88af..4717a0043b 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/login.html +++ b/src/pretix/control/templates/pretixcontrol/auth/login.html @@ -3,20 +3,25 @@ {% load i18n %} {% load staticfiles %} {% block content %} - -
- {% bootstrap_form_errors form type='all' layout='inline' %} - {% csrf_token %} - {% bootstrap_field form.email %} - {% bootstrap_field form.password %} -
+ + {% bootstrap_form_errors form type='all' layout='inline' %} + {% csrf_token %} + {% bootstrap_field form.email %} + {% bootstrap_field form.password %} +
+ + {% trans "Lost password?" %} + + + +
+ +
{% trans "Register" %} - - -
- +
+ {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/auth/recover.html b/src/pretix/control/templates/pretixcontrol/auth/recover.html new file mode 100644 index 0000000000..2ff140d0d4 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/auth/recover.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/auth/base.html" %} +{% load bootstrap3 %} +{% load staticfiles %} +{% load i18n %} +{% block content %} +
+

{% trans "Set new password" %}

+ {% csrf_token %} + {% bootstrap_form_errors form type='all' layout='inline' %} + {% bootstrap_field form.password %} + {% bootstrap_field form.password_repeat %} +
+ + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/auth/register.html b/src/pretix/control/templates/pretixcontrol/auth/register.html index ba850244ce..3fdaac8ecc 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/register.html +++ b/src/pretix/control/templates/pretixcontrol/auth/register.html @@ -3,7 +3,6 @@ {% load staticfiles %} {% load i18n %} {% block content %} -

{% trans "Create a new account" %}

{% bootstrap_form_errors form type='all' layout='inline' %} diff --git a/src/pretix/control/templates/pretixcontrol/email/forgot.txt b/src/pretix/control/templates/pretixcontrol/email/forgot.txt new file mode 100644 index 0000000000..798a25aed2 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/email/forgot.txt @@ -0,0 +1,9 @@ +{% load i18n %}{% blocktrans with url=url|safe %}Hello, + +you requested a new password. Please go to the following page to reset your password: + +{{ url }} + +Best regards, +Your pretix team +{% endblocktrans %} \ No newline at end of file diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 83fa379a1a..f2cf94dcb7 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -8,6 +8,8 @@ urlpatterns = [ url(r'^logout$', auth.logout, name='auth.logout'), url(r'^login$', auth.login, name='auth.login'), url(r'^register$', auth.register, name='auth.register'), + url(r'^forgot$', auth.Forgot.as_view(), name='auth.forgot'), + url(r'^forgot/recover$', auth.Recover.as_view(), name='auth.forgot.recover'), url(r'^$', main.index, name='index'), url(r'^settings$', user.UserSettings.as_view(), name='user.settings'), url(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'), diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index 73a1e16a07..be74cc989a 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -1,11 +1,20 @@ from django.conf import settings +from django.contrib import messages from django.contrib.auth import ( authenticate, login as auth_login, logout as auth_logout, ) +from django.contrib.auth.tokens import default_token_generator from django.shortcuts import redirect, render +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import TemplateView -from pretix.base.forms.auth import LoginForm, RegistrationForm +from pretix.base.forms.auth import ( + LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm, +) from pretix.base.models import User +from pretix.base.services.mail import mail +from pretix.helpers.urls import build_absolute_uri def login(request): @@ -15,9 +24,7 @@ def login(request): """ ctx = {} if request.user.is_authenticated(): - if "next" in request.GET: - return redirect(request.GET.get("next", 'control:index')) - return redirect('control:index') + return redirect(request.GET.get("next", 'control:index')) if request.method == 'POST': form = LoginForm(data=request.POST) if form.is_valid() and form.user_cache: @@ -45,9 +52,7 @@ def register(request): """ ctx = {} if request.user.is_authenticated(): - if "next" in request.GET: - return redirect(request.GET.get("next", 'control:index')) - return redirect('control:index') + return redirect(request.GET.get("next", 'control:index')) if request.method == 'POST': form = RegistrationForm(data=request.POST) if form.is_valid(): @@ -63,3 +68,88 @@ def register(request): form = RegistrationForm() ctx['form'] = form return render(request, 'pretixcontrol/auth/register.html', ctx) + + +class Forgot(TemplateView): + template_name = 'pretixcontrol/auth/forgot.html' + + def get(self, request, *args, **kwargs): + if request.user.is_authenticated(): + return redirect(request.GET.get("next", 'control:index')) + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + if self.form.is_valid(): + user = self.form.cleaned_data['user'] + mail( + user.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt', + { + 'user': user, + 'url': (build_absolute_uri('control:auth.forgot.recover') + + '?id=%d&token=%s' % (user.id, default_token_generator.make_token(user))) + }, + None, locale=user.locale + ) + messages.success(request, _('We sent you an e-mail containing further instructions.')) + return redirect('control:auth.forgot') + else: + return self.get(request, *args, **kwargs) + + @cached_property + def form(self): + return PasswordForgotForm(data=self.request.POST if self.request.method == 'POST' else None) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['form'] = self.form + return context + + +class Recover(TemplateView): + template_name = 'pretixcontrol/auth/recover.html' + + error_messages = { + 'invalid': _('You clicked on an invalid link. Please check that you copied the full ' + 'web address into your address bar. Please note that the link is only valid ' + 'for three days and that the link can only be used once.'), + 'unknownuser': _('We were unable to find the user you requested a new password for.') + } + + def get(self, request, *args, **kwargs): + if request.user.is_authenticated(): + return redirect(request.GET.get("next", 'control:index')) + try: + user = User.objects.get(id=self.request.GET.get('id')) + except User.DoesNotExist: + return self.invalid('unknownuser') + if not default_token_generator.check_token(user, self.request.GET.get('token')): + return self.invalid('invalid') + return super().get(request, *args, **kwargs) + + def invalid(self, msg): + messages.error(self.request, self.error_messages[msg]) + return redirect('control:auth.forgot') + + def post(self, request, *args, **kwargs): + if self.form.is_valid(): + try: + user = User.objects.get(id=self.request.GET.get('id')) + except User.DoesNotExist: + return self.invalid('unknownuser') + if not default_token_generator.check_token(user, self.request.GET.get('token')): + return self.invalid('invalid') + user.set_password(self.form.cleaned_data['password']) + user.save() + messages.success(request, _('You can now login using your new password.')) + return redirect('control:auth.login') + else: + return self.get(request, *args, **kwargs) + + @cached_property + def form(self): + return PasswordRecoverForm(data=self.request.POST if self.request.method == 'POST' else None) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['form'] = self.form + return context diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 08bd4c5e1d..cc45cab43f 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -6,8 +6,6 @@ from django.contrib.auth import ( authenticate, login, logout, update_session_auth_hash, ) from django.contrib.auth.tokens import default_token_generator -from django.core import signing -from django.core.signing import BadSignature, SignatureExpired from django.core.urlresolvers import reverse from django.db.models import Count from django.shortcuts import redirect @@ -27,7 +25,6 @@ from pretix.presale.forms.checkout import GuestForm from pretix.presale.views import ( CartDisplayMixin, EventViewMixin, LoginRequiredMixin, ) -from pretix.presale.views.cart import CartAdd class EventIndex(EventViewMixin, CartDisplayMixin, TemplateView): diff --git a/src/static/pretixcontrol/less/auth.less b/src/static/pretixcontrol/less/auth.less index cb4155d9e2..a65c646d97 100644 --- a/src/static/pretixcontrol/less/auth.less +++ b/src/static/pretixcontrol/less/auth.less @@ -37,3 +37,9 @@ footer { margin-top: 0; } } + +.container > .alert { + max-width: 330px; + margin: auto; + margin-bottom: 20px; +} \ No newline at end of file diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index 5695f61397..dde5672fd8 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -1,4 +1,11 @@ -from django.test import Client, TestCase +from datetime import date, timedelta + +from django.conf import settings +from django.contrib.auth.tokens import ( + PasswordResetTokenGenerator, default_token_generator, +) +from django.core import mail as djmail +from django.test import TestCase from tests.base import BrowserTest from pretix.base.models import User @@ -123,3 +130,122 @@ class RegistrationFormTest(TestCase): 'password_repeat': 'foo' }) self.assertEqual(response.status_code, 302) + + +class PasswordRecoveryFormTest(TestCase): + def setUp(self): + super().setUp() + self.user = User.objects.create_user('demo@demo.dummy', 'demo') + + def test_unknown(self): + response = self.client.post('/control/forgot', { + 'email': 'dummy@dummy.dummy', + }) + self.assertEqual(response.status_code, 200) + + def test_email_sent(self): + djmail.outbox = [] + + response = self.client.post('/control/forgot', { + 'email': 'demo@demo.dummy', + }) + self.assertEqual(response.status_code, 302) + + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].to == [self.user.email] + assert "recover?id=%d&token=" % self.user.id in djmail.outbox[0].body + + def test_recovery_unknown_user(self): + response = self.client.get('/control/forgot/recover?id=0&token=foo') + self.assertEqual(response.status_code, 302) + response = self.client.post( + '/control/forgot/recover?id=0&token=foo', + { + 'password': 'foobar', + 'password_repeat': 'foobar' + } + ) + self.assertEqual(response.status_code, 302) + self.user = User.objects.get(id=self.user.id) + self.assertTrue(self.user.check_password('demo')) + + def test_recovery_invalid_token(self): + response = self.client.get('/control/forgot/recover?id=%d&token=foo' % self.user.id) + self.assertEqual(response.status_code, 302) + response = self.client.post( + '/control/forgot/recover?id=%d&token=foo' % self.user.id, + { + 'password': 'foobar', + 'password_repeat': 'foobar' + } + ) + self.assertEqual(response.status_code, 302) + self.user = User.objects.get(id=self.user.id) + self.assertTrue(self.user.check_password('demo')) + + def test_recovery_expired_token(self): + class Mocked(PasswordResetTokenGenerator): + def _today(self): + return date.today() - timedelta(settings.PASSWORD_RESET_TIMEOUT_DAYS + 1) + + generator = Mocked() + token = generator.make_token(self.user) + response = self.client.get( + '/control/forgot/recover?id=%d&token=%s' % (self.user.id, token) + ) + self.assertEqual(response.status_code, 302) + response = self.client.post( + '/control/forgot/recover?id=%d&token=%s' % (self.user.id, token), + { + 'password': 'foobar', + 'password_repeat': 'foobar' + } + ) + self.assertEqual(response.status_code, 302) + self.user = User.objects.get(id=self.user.id) + self.assertTrue(self.user.check_password('demo')) + + def test_recovery_valid_token_success(self): + token = default_token_generator.make_token(self.user) + response = self.client.get('/control/forgot/recover?id=%d&token=%s' % (self.user.id, token)) + self.assertEqual(response.status_code, 200) + response = self.client.post( + '/control/forgot/recover?id=%d&token=%s' % (self.user.id, token), + { + 'password': 'foobar', + 'password_repeat': 'foobar' + } + ) + self.assertEqual(response.status_code, 302) + self.user = User.objects.get(id=self.user.id) + self.assertTrue(self.user.check_password('foobar')) + + def test_recovery_valid_token_empty_passwords(self): + token = default_token_generator.make_token(self.user) + response = self.client.get('/control/forgot/recover?id=%d&token=%s' % (self.user.id, token)) + self.assertEqual(response.status_code, 200) + response = self.client.post( + '/control/forgot/recover?id=%d&token=%s' % (self.user.id, token), + { + 'password': '', + 'password_repeat': 'foobar' + } + ) + self.assertEqual(response.status_code, 200) + self.user = User.objects.get(id=self.user.id) + self.assertTrue(self.user.check_password('demo')) + + def test_recovery_valid_token_different_passwords(self): + token = default_token_generator.make_token(self.user) + response = self.client.get('/control/forgot/recover?id=%d&token=%s' % (self.user.id, token)) + self.assertEqual(response.status_code, 200) + response = self.client.post( + '/control/forgot/recover?id=%d&token=%s' % (self.user.id, token), + { + 'password': 'foo', + 'password_repeat': 'foobar' + } + ) + self.assertEqual(response.status_code, 200) + self.user = User.objects.get(id=self.user.id) + self.assertTrue(self.user.check_password('demo'))