Added password reset to control.auth

This commit is contained in:
Raphael Michel
2015-10-04 13:52:08 +02:00
parent 4e8707635f
commit c47008cc18
14 changed files with 353 additions and 67 deletions

View File

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

View File

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

View File

@@ -20,7 +20,9 @@ class PermissionMiddleware:
EXCEPTIONS = (
"auth.login",
"auth.register"
"auth.register",
"auth.forgot",
"auth.forgot.recover"
)
def process_request(self, request):

View File

@@ -3,24 +3,34 @@
{% load staticfiles %}
<!DOCTYPE html>
<html>
<head>
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
{% compress css %}
<link rel="stylesheet" type="text/less" href="{% static "pretixcontrol/less/auth.less" %}" />
{% endcompress %}
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
{% block content %}
{% endblock %}
<footer>
{% with "href='http://pretix.eu'" as a_attr %}
{% blocktrans trimmed %}
powered by <a {{ a_attr }}>pretix</a>
{% endblocktrans %}
{% endwith %}
</footer>
</div>
</body>
<head>
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
{% compress css %}
<link rel="stylesheet" type="text/less" href="{% static "pretixcontrol/less/auth.less" %}"/>
{% endcompress %}
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<div class="container">
<a href="{% url "control:auth.login" %}">
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo"/>
</a>
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% block content %}
{% endblock %}
<footer>
{% with "href='http://pretix.eu'" as a_attr %}
{% blocktrans trimmed %}
powered by <a {{ a_attr }}>pretix</a>
{% endblocktrans %}
{% endwith %}
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,18 @@
{% extends "pretixcontrol/auth/base.html" %}
{% load bootstrap3 %}
{% load staticfiles %}
{% load i18n %}
{% block content %}
<form class="form-signin" action="" method="post">
<h3>{% trans "Password recovery" %}</h3>
{% csrf_token %}
{% bootstrap_form_errors form type='all' layout='inline' %}
{% bootstrap_field form.email %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary">
{% trans "Send recovery information" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -3,20 +3,25 @@
{% load i18n %}
{% load staticfiles %}
{% block content %}
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo" />
<form class="form-signin" action="" method="post">
{% bootstrap_form_errors form type='all' layout='inline' %}
{% csrf_token %}
{% bootstrap_field form.email %}
{% bootstrap_field form.password %}
<div class="form-group buttons">
<form class="form-signin" action="" method="post">
{% bootstrap_form_errors form type='all' layout='inline' %}
{% csrf_token %}
{% bootstrap_field form.email %}
{% bootstrap_field form.password %}
<div class="form-group buttons">
<a href="{% url "control:auth.forgot" %}" class="btn btn-link">
{% trans "Lost password?" %}
</a>
<button type="submit" class="btn btn-primary">
{% trans "Log in" %}
</button>
</div>
<div class="form-group buttons">
<a href="{% url "control:auth.register" %}" class="btn btn-link">
{% trans "Register" %}
</a>
<button type="submit" class="btn btn-primary">
{% trans "Log in" %}
</button>
</div>
</form>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "pretixcontrol/auth/base.html" %}
{% load bootstrap3 %}
{% load staticfiles %}
{% load i18n %}
{% block content %}
<form class="form-signin" action="" method="post">
<h3>{% trans "Set new password" %}</h3>
{% csrf_token %}
{% bootstrap_form_errors form type='all' layout='inline' %}
{% bootstrap_field form.password %}
{% bootstrap_field form.password_repeat %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -3,7 +3,6 @@
{% load staticfiles %}
{% load i18n %}
{% block content %}
<img src="{% static "pretixbase/img/pretix-logo.svg" %}" class="logo" />
<form class="form-signin" action="" method="post">
<h3>{% trans "Create a new account" %}</h3>
{% bootstrap_form_errors form type='all' layout='inline' %}

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,3 +37,9 @@ footer {
margin-top: 0;
}
}
.container > .alert {
max-width: 330px;
margin: auto;
margin-bottom: 20px;
}

View File

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