diff --git a/doc/admin/config.rst b/doc/admin/config.rst index 8b9f4f5286..e89fa23dff 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -60,6 +60,9 @@ Example:: ``password_reset`` Enables or disables password reset. Defaults to ``on``. +``long_sessions`` + Enables or disables the "keep me logged in" button. Defaults to ``on``. + ``ecb_rates`` By default, pretix periodically downloads a XML file from the European Central Bank to retrieve exchange rates that are used to print tax amounts in the customer currency on invoices for some currencies. Set to ``off`` to diff --git a/src/pretix/base/forms/auth.py b/src/pretix/base/forms/auth.py index c38fa3e39a..8162d3173f 100644 --- a/src/pretix/base/forms/auth.py +++ b/src/pretix/base/forms/auth.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth.password_validation import ( password_validators_help_texts, validate_password, @@ -15,6 +16,7 @@ class LoginForm(forms.Form): """ email = forms.EmailField(label=_("E-mail"), max_length=254) password = forms.CharField(label=_("Password"), widget=forms.PasswordInput) + keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False) error_messages = { 'invalid_login': _("Please enter a correct email address and password."), @@ -29,6 +31,8 @@ class LoginForm(forms.Form): self.request = request self.user_cache = None super().__init__(*args, **kwargs) + if not settings.PRETIX_LONG_SESSIONS: + del self.fields['keep_logged_in'] def clean(self): email = self.cleaned_data.get('email') @@ -90,6 +94,12 @@ class RegistrationForm(forms.Form): }), required=True ) + keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not settings.PRETIX_LONG_SESSIONS: + del self.fields['keep_logged_in'] def clean(self): password1 = self.cleaned_data.get('password', '') diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index 2870a2f224..c1b5caa20f 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -1,8 +1,9 @@ -from urllib.parse import urljoin, urlparse +import time +from urllib.parse import quote, urljoin, urlparse from django.conf import settings -from django.contrib.auth import REDIRECT_FIELD_NAME -from django.core.urlresolvers import get_script_prefix, resolve +from django.contrib.auth import REDIRECT_FIELD_NAME, logout +from django.core.urlresolvers import get_script_prefix, resolve, reverse from django.http import Http404 from django.shortcuts import redirect, resolve_url from django.utils.deprecation import MiddlewareMixin @@ -28,6 +29,24 @@ class PermissionMiddleware(MiddlewareMixin): "auth.invite", ) + def _login_redirect(self, request): + # Taken from django/contrib/auth/decorators.py + path = request.build_absolute_uri() + # urlparse chokes on lazy objects in Python 3, force to str + resolved_login_url = force_str( + resolve_url(settings.LOGIN_URL_CONTROL)) + # If the login url is the same scheme and net location then just + # use the path as the "next" url. + login_scheme, login_netloc = urlparse(resolved_login_url)[:2] + current_scheme, current_netloc = urlparse(path)[:2] + if ((not login_scheme or login_scheme == current_scheme) and + (not login_netloc or login_netloc == current_netloc)): + path = request.get_full_path() + from django.contrib.auth.views import redirect_to_login + + return redirect_to_login( + path, resolved_login_url, REDIRECT_FIELD_NAME) + def process_request(self, request): url = resolve(request.path_info) url_name = url.url_name @@ -42,22 +61,18 @@ class PermissionMiddleware(MiddlewareMixin): if url_name in self.EXCEPTIONS: return if not request.user.is_authenticated: - # Taken from django/contrib/auth/decorators.py - path = request.build_absolute_uri() - # urlparse chokes on lazy objects in Python 3, force to str - resolved_login_url = force_str( - resolve_url(settings.LOGIN_URL_CONTROL)) - # If the login url is the same scheme and net location then just - # use the path as the "next" url. - login_scheme, login_netloc = urlparse(resolved_login_url)[:2] - current_scheme, current_netloc = urlparse(path)[:2] - if ((not login_scheme or login_scheme == current_scheme) and - (not login_netloc or login_netloc == current_netloc)): - path = request.get_full_path() - from django.contrib.auth.views import redirect_to_login + return self._login_redirect(request) - return redirect_to_login( - path, resolved_login_url, REDIRECT_FIELD_NAME) + if not settings.PRETIX_LONG_SESSIONS or not request.session.get('pretix_auth_long_session', False): + last_used = request.session.get('pretix_auth_last_used', 0) or time.time() + if time.time() - request.session.get('pretix_auth_login_time', 0) > settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE: + logout(request) + request.session['pretix_auth_login_time'] = 0 + return self._login_redirect(request) + if time.time() - last_used > settings.PRETIX_SESSION_TIMEOUT_RELATIVE and url_name != 'user.reauth': + return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path())) + + request.session['pretix_auth_last_used'] = int(time.time()) if 'event' in url.kwargs and 'organizer' in url.kwargs: request.event = Event.objects.filter( diff --git a/src/pretix/control/templates/pretixcontrol/auth/login.html b/src/pretix/control/templates/pretixcontrol/auth/login.html index b8e17be4f3..75f5518e2c 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/login.html +++ b/src/pretix/control/templates/pretixcontrol/auth/login.html @@ -8,24 +8,23 @@ {% csrf_token %} {% bootstrap_field form.email %} {% bootstrap_field form.password %} + {% if form.keep_logged_in %} + {% bootstrap_field form.keep_logged_in %} + {% endif %}
+ {% if can_reset %} - + {% trans "Lost password?" %} {% endif %} - - -
- - {% if can_register %} -
- + {% if can_register %} + {% trans "Register" %} -
- {% endif %} + {% endif %} + {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/auth/register.html b/src/pretix/control/templates/pretixcontrol/auth/register.html index 3fdaac8ecc..4d2e0646bf 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/register.html +++ b/src/pretix/control/templates/pretixcontrol/auth/register.html @@ -10,6 +10,9 @@ {% bootstrap_field form.email %} {% bootstrap_field form.password %} {% bootstrap_field form.password_repeat %} + {% if form.keep_logged_in %} + {% bootstrap_field form.keep_logged_in %} + {% endif %}
« {% trans "Login" %} diff --git a/src/pretix/control/templates/pretixcontrol/user/reauth.html b/src/pretix/control/templates/pretixcontrol/user/reauth.html index d5cf90a355..cda2eac90e 100644 --- a/src/pretix/control/templates/pretixcontrol/user/reauth.html +++ b/src/pretix/control/templates/pretixcontrol/user/reauth.html @@ -1,26 +1,30 @@ -{% extends "pretixcontrol/base.html" %} +{% extends "pretixcontrol/auth/base.html" %} {% load i18n %} {% load bootstrap3 %} {% block content %} -
-
-
- {% csrf_token %} -

{% trans "Confirm password" %}

-

- {% trans "Please confirm your password to continue with this operation. We'll remember your password for an hour or until you log out." %} -

-
- -
-
- -
- -
+
+
+ +
+
+ + {% endblock %} diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index 03811b336e..51a02bef7c 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -42,6 +42,9 @@ def login(request): if request.method == 'POST': form = LoginForm(data=request.POST) if form.is_valid() and form.user_cache: + request.session['pretix_auth_long_session'] = ( + settings.PRETIX_LONG_SESSIONS and form.cleaned_data.get('keep_logged_in', False) + ) if form.user_cache.require_2fa: request.session['pretix_auth_2fa_user'] = form.user_cache.pk request.session['pretix_auth_2fa_time'] = str(int(time.time())) @@ -93,6 +96,9 @@ def register(request): user.log_action('pretix.control.auth.user.created', user=user) auth_login(request, user) request.session['pretix_auth_login_time'] = int(time.time()) + request.session['pretix_auth_long_session'] = ( + settings.PRETIX_LONG_SESSIONS and form.cleaned_data.get('keep_logged_in', False) + ) return redirect('control:index') else: form = RegistrationForm() @@ -144,6 +150,9 @@ def invite(request, token): user.log_action('pretix.control.auth.user.created', user=user) auth_login(request, user) request.session['pretix_auth_login_time'] = int(time.time()) + request.session['pretix_auth_long_session'] = ( + settings.PRETIX_LONG_SESSIONS and form.cleaned_data.get('keep_logged_in', False) + ) with transaction.atomic(): inv.team.members.add(request.user) diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index 2b1d04518e..70fa3da6d8 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -42,7 +42,9 @@ class ReauthView(TemplateView): def post(self, request, *args, **kwargs): password = request.POST.get("password", "") if request.user.check_password(password): - request.session['pretix_auth_login_time'] = int(time.time()) + t = int(time.time()) + request.session['pretix_auth_login_time'] = t + request.session['pretix_auth_last_used'] = t if "next" in request.GET and is_safe_url(request.GET.get("next")): return redirect(request.GET.get("next")) return redirect(reverse('control:index')) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 401265e532..b7bfb124f9 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -84,6 +84,9 @@ MEDIA_URL = config.get('urls', 'media', fallback='/media/') PRETIX_INSTANCE_NAME = config.get('pretix', 'instance_name', fallback='pretix.de') PRETIX_REGISTRATION = config.getboolean('pretix', 'registration', fallback=True) PRETIX_PASSWORD_RESET = config.getboolean('pretix', 'password_reset', fallback=True) +PRETIX_LONG_SESSIONS = config.getboolean('pretix', 'long_sessions', fallback=True) +PRETIX_SESSION_TIMEOUT_RELATIVE = 3600 * 3 +PRETIX_SESSION_TIMEOUT_ABSOLUTE = 3600 * 12 SITE_URL = config.get('pretix', 'url', fallback='http://localhost') if SITE_URL.endswith('/'): diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index c806d78e8d..877b74c685 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -7,7 +7,7 @@ from django.contrib.auth.tokens import ( PasswordResetTokenGenerator, default_token_generator, ) from django.core import mail as djmail -from django.test import TestCase +from django.test import TestCase, override_settings from django_otp.oath import TOTP from django_otp.plugins.otp_totp.models import TOTPDevice from u2flib_server.jsapi import JSONDict @@ -33,6 +33,17 @@ class LoginFormTest(TestCase): 'password': 'dummy', }) self.assertEqual(response.status_code, 302) + assert time.time() - self.client.session['pretix_auth_login_time'] < 60 + assert not self.client.session['pretix_auth_long_session'] + + def test_set_long_session(self): + response = self.client.post('/control/login', { + 'email': 'dummy@dummy.dummy', + 'password': 'dummy', + 'keep_logged_in': 'on' + }) + self.assertEqual(response.status_code, 302) + assert self.client.session['pretix_auth_long_session'] def test_inactive_account(self): self.user.is_active = False @@ -187,6 +198,8 @@ class RegistrationFormTest(TestCase): 'password_repeat': 'foobarbar' }) self.assertEqual(response.status_code, 302) + assert time.time() - self.client.session['pretix_auth_login_time'] < 60 + assert not self.client.session['pretix_auth_long_session'] @pytest.fixture @@ -202,6 +215,7 @@ class Login2FAFormTest(TestCase): session = self.client.session session['pretix_auth_2fa_user'] = self.user.pk session['pretix_auth_2fa_time'] = str(int(time.time())) + session['pretix_auth_long_session'] = False session.save() def test_invalid_session(self): @@ -245,6 +259,8 @@ class Login2FAFormTest(TestCase): }) self.assertEqual(response.status_code, 302) self.assertIn('/control/events/', response['Location']) + assert time.time() - self.client.session['pretix_auth_login_time'] < 60 + assert not self.client.session['pretix_auth_long_session'] def test_u2f_invalid(self): def fail(*args, **kwargs): @@ -475,3 +491,90 @@ class PasswordRecoveryFormTest(TestCase): self.assertEqual(response.status_code, 200) self.user = User.objects.get(id=self.user.id) self.assertTrue(self.user.check_password('demo')) + + +class SessionTimeOutTest(TestCase): + def setUp(self): + super().setUp() + self.user = User.objects.create_user('demo@demo.dummy', 'demo') + self.client.login(email='demo@demo.dummy', password='demo') + + def test_log_out_after_absolute_timeout(self): + session = self.client.session + session['pretix_auth_long_session'] = False + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 12 - 60 + session.save() + + response = self.client.get('/control/') + self.assertEqual(response.status_code, 302) + + def test_dont_logout_before_absolute_timeout(self): + session = self.client.session + session['pretix_auth_long_session'] = True + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 12 + 60 + session.save() + + response = self.client.get('/control/') + self.assertEqual(response.status_code, 200) + + @override_settings(PRETIX_LONG_SESSIONS=False) + def test_ignore_long_session_if_disabled_in_config(self): + session = self.client.session + session['pretix_auth_long_session'] = True + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 12 - 60 + session.save() + + response = self.client.get('/control/') + self.assertEqual(response.status_code, 302) + + def test_dont_logout_in_long_session(self): + session = self.client.session + session['pretix_auth_long_session'] = True + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 12 - 60 + session.save() + + response = self.client.get('/control/') + self.assertEqual(response.status_code, 200) + + def test_log_out_after_relative_timeout(self): + session = self.client.session + session['pretix_auth_long_session'] = False + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 6 + session['pretix_auth_last_used'] = int(time.time()) - 3600 * 3 - 60 + session.save() + + response = self.client.get('/control/') + self.assertEqual(response.status_code, 302) + + def test_dont_logout_before_relative_timeout(self): + session = self.client.session + session['pretix_auth_long_session'] = True + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 6 + session['pretix_auth_last_used'] = int(time.time()) - 3600 * 3 + 60 + session.save() + + response = self.client.get('/control/') + self.assertEqual(response.status_code, 200) + + def test_dont_logout_by_relative_in_long_session(self): + session = self.client.session + session['pretix_auth_long_session'] = True + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 5 + session['pretix_auth_last_used'] = int(time.time()) - 3600 * 3 - 60 + session.save() + + response = self.client.get('/control/') + self.assertEqual(response.status_code, 200) + + def test_update_session_activity(self): + t1 = int(time.time()) - 5 + session = self.client.session + session['pretix_auth_long_session'] = False + session['pretix_auth_login_time'] = int(time.time()) - 3600 * 5 + session['pretix_auth_last_used'] = t1 + session.save() + + response = self.client.get('/control/') + self.assertEqual(response.status_code, 200) + + assert self.client.session['pretix_auth_last_used'] > t1