diff --git a/doc/admin/config.rst b/doc/admin/config.rst index a49b0113f1..3c69c08277 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -57,6 +57,9 @@ Example:: A comma-separated list of plugins that are not available even though they are installed. Defaults to an empty string. +``auth_backends`` + A comma-separated list of available auth backends. Defaults to ``pretix.base.auth.NativeAuthBackend``. + ``cookie_domain`` The cookie domain to be set. Defaults to ``None``. diff --git a/doc/development/api/index.rst b/doc/development/api/index.rst index 6340479e4c..08988a00a5 100644 --- a/doc/development/api/index.rst +++ b/doc/development/api/index.rst @@ -16,5 +16,6 @@ Contents: invoice shredder customview + auth general quality diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 4c073457cc..d126e31b2e 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -76,6 +76,7 @@ overpayment param passphrase percental +pluggable positionid pre prepend diff --git a/src/pretix/base/auth.py b/src/pretix/base/auth.py new file mode 100644 index 0000000000..a359ed93c8 --- /dev/null +++ b/src/pretix/base/auth.py @@ -0,0 +1,109 @@ +from collections import OrderedDict +from importlib import import_module + +from django import forms +from django.conf import settings +from django.contrib.auth import authenticate +from django.utils.translation import gettext_lazy as _ + + +def get_auth_backends(): + backends = {} + for b in settings.PRETIX_AUTH_BACKENDS: + mod, name = b.rsplit('.', 1) + b = getattr(import_module(mod), name)() + backends[b.identifier] = b + return backends + + +class BaseAuthBackend: + """ + This base class defines the interface that needs to be implemented by every class that supplies + an authentication method to pretix. Please note that pretix authentication backends are different + from plain Django authentication backends! Be sure to read the documentation chapter on authentication + backends before you implement one. + """ + + @property + def identifier(self): + """ + A short and unique identifier for this authentication backend. + This should only contain lowercase letters and in most cases will + be the same as your package name. + """ + raise NotImplementedError() + + @property + def verbose_name(self): + """ + A human-readable name of this authentication backend. + """ + raise NotImplementedError() + + @property + def visible(self): + """ + Whether or not this backend can be selected by users actively. Set this to ``False`` + if you only implement ``request_authenticate``. + """ + return True + + @property + def login_form_fields(self) -> dict: + """ + This property may return form fields that the user needs to fill in to log in. + """ + return {} + + def form_authenticate(self, request, form_data): + """ + This method will be called after the user filled in the login form. ``request`` will contain + the current request and ``form_data`` the input for the form fields defined in ``login_form_fields``. + You are expected to either return a ``User`` object (if login was successful) or ``None``. + """ + return + + def request_authenticate(self, request): + """ + This method will be called when the user opens the login form. If the user already has a valid session + according to your login mechanism, for example a cookie set by a different system or HTTP header set by a + reverse proxy, you can directly return a ``User`` object that will be logged in. + + ``request`` will contain the current request. + You are expected to either return a ``User`` object (if login was successful) or ``None``. + """ + return + + def authentication_url(self, request): + """ + This method will be called to populate the URL for your authentication method's tab on the login page. + For example, if your method works through OAuth, you could return the URL of the OAuth authorization URL the + user needs to visit. + + If you return ``None`` (the default), the link will point to a page that shows the form defined by + ``login_form_fields``. + """ + return + + +class NativeAuthBackend(BaseAuthBackend): + identifier = 'native' + verbose_name = _('pretix User') + + @property + def login_form_fields(self) -> dict: + """ + This property may return form fields that the user needs to fill in + to log in. + """ + d = OrderedDict([ + ('email', forms.EmailField(label=_("E-mail"), max_length=254, + widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))), + ('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput)), + ]) + return d + + def form_authenticate(self, request, form_data): + u = authenticate(request=request, email=form_data['email'].lower(), password=form_data['password']) + if u and u.auth_backend == self.identifier: + return u diff --git a/src/pretix/base/forms/auth.py b/src/pretix/base/forms/auth.py index 25b79505f3..942424515b 100644 --- a/src/pretix/base/forms/auth.py +++ b/src/pretix/base/forms/auth.py @@ -1,6 +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, ) @@ -14,32 +13,33 @@ class LoginForm(forms.Form): Base class for authenticating users. Extend this to get a form that accepts username/password logins. """ - email = forms.EmailField(label=_("E-mail"), max_length=254, widget=forms.EmailInput(attrs={'autofocus': 'autofocus'})) - 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."), + 'invalid_login': _("This combination of credentials is not known to our system."), 'inactive': _("This account is inactive.") } - def __init__(self, request=None, *args, **kwargs): + def __init__(self, backend, request=None, *args, **kwargs): """ The 'request' parameter is set for custom auth use by subclasses. The form data comes in via the standard 'data' kwarg. """ self.request = request self.user_cache = None + self.backend = backend super().__init__(*args, **kwargs) + for k, f in backend.login_form_fields.items(): + self.fields[k] = f + if not settings.PRETIX_LONG_SESSIONS: del self.fields['keep_logged_in'] + else: + self.fields.move_to_end('keep_logged_in') def clean(self): - email = self.cleaned_data.get('email') - password = self.cleaned_data.get('password') - - if email and password: - self.user_cache = authenticate(request=self.request, email=email.lower(), password=password) + if all(k in self.cleaned_data for k, f in self.fields.items() if f.required): + self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data) if self.user_cache is None: raise forms.ValidationError( self.error_messages['invalid_login'], @@ -181,3 +181,44 @@ class PasswordForgotForm(forms.Form): def clean_email(self): return self.cleaned_data['email'] + + +class ReauthForm(forms.Form): + error_messages = { + 'invalid_login': _("This combination of credentials is not known to our system."), + 'inactive': _("This account is inactive.") + } + + def __init__(self, backend, user, request=None, *args, **kwargs): + """ + The 'request' parameter is set for custom auth use by subclasses. + The form data comes in via the standard 'data' kwarg. + """ + self.request = request + self.user = user + self.backend = backend + super().__init__(*args, **kwargs) + for k, f in backend.login_form_fields.items(): + self.fields[k] = f + if 'email' in self.fields: + self.fields['email'].disabled = True + + def clean(self): + self.cleaned_data['email'] = self.user.email + user_cache = self.backend.form_authenticate(self.request, self.cleaned_data) + if user_cache != self.user: + raise forms.ValidationError( + self.error_messages['invalid_login'], + code='invalid_login' + ) + else: + self.confirm_login_allowed(user_cache) + + return self.cleaned_data + + def confirm_login_allowed(self, user: User): + if not user.is_active: + raise forms.ValidationError( + self.error_messages['inactive'], + code='inactive', + ) diff --git a/src/pretix/base/forms/user.py b/src/pretix/base/forms/user.py index 983716e9a5..fbb594a47e 100644 --- a/src/pretix/base/forms/user.py +++ b/src/pretix/base/forms/user.py @@ -56,6 +56,11 @@ class UserSettingsForm(forms.ModelForm): self.user = kwargs.pop('user') super().__init__(*args, **kwargs) self.fields['email'].required = True + if self.user.auth_backend != 'native': + del self.fields['old_pw'] + del self.fields['new_pw'] + del self.fields['new_pw_repeat'] + self.fields['email'].disabled = True def clean_old_pw(self): old_pw = self.cleaned_data.get('old_pw') diff --git a/src/pretix/base/migrations/0137_auto_20191015_1141.py b/src/pretix/base/migrations/0137_auto_20191015_1141.py new file mode 100644 index 0000000000..4738e83dbd --- /dev/null +++ b/src/pretix/base/migrations/0137_auto_20191015_1141.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.4 on 2019-10-15 11:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0136_auto_20190918_1742'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='auth_backend', + field=models.CharField(default='native', max_length=255), + ), + ] diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 5d87306b09..74c3365f1d 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -109,6 +109,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): help_text=_('If turned off, you will not get any notifications.') ) notifications_token = models.CharField(max_length=255, default=generate_notifications_token) + auth_backend = models.CharField(max_length=255, default='native') objects = UserManager() diff --git a/src/pretix/control/forms/users.py b/src/pretix/control/forms/users.py index 65903ef37e..5195d9a362 100644 --- a/src/pretix/control/forms/users.py +++ b/src/pretix/control/forms/users.py @@ -56,6 +56,10 @@ class UserEditForm(forms.ModelForm): super().__init__(*args, **kwargs) self.fields['email'].required = True self.fields['last_login'].disabled = True + if self.instance and self.instance.auth_backend != 'native': + del self.fields['new_pw'] + del self.fields['new_pw_repeat'] + self.fields['email'].disabled = True def clean_email(self): email = self.cleaned_data['email'] diff --git a/src/pretix/control/templates/pretixcontrol/auth/login.html b/src/pretix/control/templates/pretixcontrol/auth/login.html index a51f18f9da..9aeb518138 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/login.html +++ b/src/pretix/control/templates/pretixcontrol/auth/login.html @@ -2,28 +2,40 @@ {% load bootstrap3 %} {% load i18n %} {% load static %} +{% load urlreplace %} {% block content %}
- {% bootstrap_form_errors form type='all' layout='inline' %} - {% csrf_token %} - {% bootstrap_field form.email %} - {% bootstrap_field form.password %} - {% if form.keep_logged_in %} - {% bootstrap_field form.keep_logged_in %} + {% if backends|length > 1 %} + +
{% endif %} + {% csrf_token %} + {% bootstrap_form form %}
- {% if can_reset %} - - {% trans "Lost password?" %} - - {% endif %} - {% if can_register %} - - {% trans "Register" %} - + {% if backend.identifier == "native" %} + {% if can_reset %} + + {% trans "Lost password?" %} + + {% endif %} + {% if can_register %} + + {% trans "Register" %} + + {% endif %} {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/user/reauth.html b/src/pretix/control/templates/pretixcontrol/user/reauth.html index 262f1668b7..2932308ad7 100644 --- a/src/pretix/control/templates/pretixcontrol/user/reauth.html +++ b/src/pretix/control/templates/pretixcontrol/user/reauth.html @@ -10,14 +10,9 @@

{% trans "We just want to make sure it's really you. Please re-enter your password to continue." %}

-
- -
-
- -
+ {% bootstrap_form form %} + {% if jsondata %}
{% trans "WebAuthn failed. Check that the correct authentication device is correctly plugged in." %} @@ -45,6 +40,7 @@ + {% endcompress %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/user/settings.html b/src/pretix/control/templates/pretixcontrol/user/settings.html index 8daabfb9fe..139bfcbd5e 100644 --- a/src/pretix/control/templates/pretixcontrol/user/settings.html +++ b/src/pretix/control/templates/pretixcontrol/user/settings.html @@ -33,10 +33,14 @@
{% trans "Login settings" %} - {% bootstrap_field form.old_pw layout='horizontal' %} + {% if form.old_pw %} + {% bootstrap_field form.old_pw layout='horizontal' %} + {% endif %} {% bootstrap_field form.email layout='horizontal' %} - {% bootstrap_field form.new_pw layout='horizontal' %} - {% bootstrap_field form.new_pw_repeat layout='horizontal' %} + {% if form.new_pw %} + {% bootstrap_field form.new_pw layout='horizontal' %} + {% bootstrap_field form.new_pw_repeat layout='horizontal' %} + {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/users/form.html b/src/pretix/control/templates/pretixcontrol/users/form.html index 8a6e45b789..c4d93c0e2d 100644 --- a/src/pretix/control/templates/pretixcontrol/users/form.html +++ b/src/pretix/control/templates/pretixcontrol/users/form.html @@ -5,10 +5,12 @@ {% block content %}

{% trans "User" %} {{ user.email }}

-

- {% csrf_token %} - -
+ {% if user.auth_backend == "native" %} +
+ {% csrf_token %} + +
+ {% endif %}
{% csrf_token %} @@ -30,9 +32,17 @@
{% trans "Log-in settings" %} +
+ +
+ +
+
{% bootstrap_field form.email layout='control' %} - {% bootstrap_field form.new_pw layout='control' %} - {% bootstrap_field form.new_pw_repeat layout='control' %} + {% if form.new_pw %} + {% bootstrap_field form.new_pw layout='control' %} + {% bootstrap_field form.new_pw_repeat layout='control' %} + {% endif %} {% bootstrap_field form.last_login layout='control' %} {% bootstrap_field form.require_2fa layout='control' %}
diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index 01f4084750..5bbf527878 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -20,6 +20,7 @@ from django.utils.translation import ugettext_lazy as _ from django.views.generic import TemplateView from django_otp import match_token +from pretix.base.auth import get_auth_backends from pretix.base.forms.auth import ( LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm, ) @@ -30,38 +31,59 @@ from pretix.helpers.webauthn import generate_challenge logger = logging.getLogger(__name__) +def process_login(request, user, keep_logged_in): + """ + This method allows you to return a response to a successful log-in. This will set all session values correctly + and redirect to either the URL specified in the ``next`` parameter, or the 2FA login screen, or the dashboard. + + :return: This method returns a ``HttpResponse``. + """ + request.session['pretix_auth_long_session'] = settings.PRETIX_LONG_SESSIONS and keep_logged_in + if user.require_2fa: + request.session['pretix_auth_2fa_user'] = user.pk + request.session['pretix_auth_2fa_time'] = str(int(time.time())) + twofa_url = reverse('control:auth.login.2fa') + if "next" in request.GET and is_safe_url(request.GET.get("next"), allowed_hosts=None): + twofa_url += '?next=' + quote(request.GET.get('next')) + return redirect(twofa_url) + else: + auth_login(request, user) + request.session['pretix_auth_login_time'] = int(time.time()) + if "next" in request.GET and is_safe_url(request.GET.get("next"), allowed_hosts=None): + return redirect(request.GET.get("next")) + return redirect(reverse('control:index')) + + def login(request): """ Render and process a most basic login form. Takes an URL as GET parameter "next" for redirection after successful login """ ctx = {} + backenddict = get_auth_backends() + backends = sorted(backenddict.values(), key=lambda b: (b.identifier != "native", b.verbose_name)) + for b in backends: + u = b.request_authenticate(request) + if u and u.auth_backend == b.identifier: + return process_login(request, u, False) + b.url = b.authentication_url(request) + + backend = backenddict.get(request.GET.get('backend', 'native'), backends[0]) + if not backend.visible: + backend = [b for b in backends if b.visible][0] if request.user.is_authenticated: 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: - 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())) - twofa_url = reverse('control:auth.login.2fa') - if "next" in request.GET and is_safe_url(request.GET.get("next"), allowed_hosts=None): - twofa_url += '?next=' + quote(request.GET.get('next')) - return redirect(twofa_url) - else: - auth_login(request, form.user_cache) - request.session['pretix_auth_login_time'] = int(time.time()) - if "next" in request.GET and is_safe_url(request.GET.get("next"), allowed_hosts=None): - return redirect(request.GET.get("next")) - return redirect(reverse('control:index')) + form = LoginForm(backend=backend, data=request.POST) + if form.is_valid() and form.user_cache and form.user_cache.auth_backend == backend.identifier: + return process_login(request, form.user_cache, form.cleaned_data.get('keep_logged_in', False)) else: - form = LoginForm() + form = LoginForm(backend=backend) ctx['form'] = form ctx['can_register'] = settings.PRETIX_REGISTRATION ctx['can_reset'] = settings.PRETIX_PASSWORD_RESET + ctx['backends'] = backends + ctx['backend'] = backend return render(request, 'pretixcontrol/auth/login.html', ctx) @@ -83,7 +105,7 @@ def register(request): """ Render and process a basic registration form. """ - if not settings.PRETIX_REGISTRATION: + if not settings.PRETIX_REGISTRATION or 'native' not in get_auth_backends(): raise PermissionDenied('Registration is disabled') ctx = {} if request.user.is_authenticated: @@ -116,6 +138,9 @@ def invite(request, token): """ ctx = {} + if 'native' not in get_auth_backends(): + raise PermissionDenied('Invites are disabled') + try: inv = TeamInvite.objects.get(token=token) except TeamInvite.DoesNotExist: @@ -185,7 +210,7 @@ class Forgot(TemplateView): template_name = 'pretixcontrol/auth/forgot.html' def dispatch(self, request, *args, **kwargs): - if not settings.PRETIX_PASSWORD_RESET: + if not settings.PRETIX_PASSWORD_RESET or 'native' not in get_auth_backends(): raise PermissionDenied('Password reset is disabled') return super().dispatch(request, *args, **kwargs) @@ -257,15 +282,15 @@ class Recover(TemplateView): } def dispatch(self, request, *args, **kwargs): - if not settings.PRETIX_PASSWORD_RESET: - raise PermissionDenied('Password reset is disabled') + if not settings.PRETIX_PASSWORD_RESET or 'native' not in get_auth_backends(): + raise PermissionDenied('Registration is disabled') return super().dispatch(request, *args, **kwargs) 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')) + user = User.objects.get(id=self.request.GET.get('id'), auth_backend='native') except User.DoesNotExist: return self.invalid('unknownuser') if not default_token_generator.check_token(user, self.request.GET.get('token')): @@ -279,7 +304,7 @@ class Recover(TemplateView): def post(self, request, *args, **kwargs): if self.form.is_valid(): try: - user = User.objects.get(id=self.request.GET.get('id')) + user = User.objects.get(id=self.request.GET.get('id'), auth_backend='native') except User.DoesNotExist: return self.invalid('unknownuser') if not default_token_generator.check_token(user, self.request.GET.get('token')): diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 19cd2fd369..9edd28c7be 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -20,6 +20,7 @@ from django.views.generic import ( ) from pretix.api.models import WebHook +from pretix.base.auth import get_auth_backends from pretix.base.models import Device, Organizer, Team, TeamInvite, User from pretix.base.models.event import Event, EventMetaProperty from pretix.base.models.organizer import TeamAPIToken @@ -602,6 +603,9 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, if self.object.invites.filter(email__iexact=self.add_form.cleaned_data['user']).exists(): messages.error(self.request, _('This user already has been invited for this team.')) return self.get(request, *args, **kwargs) + if 'native' not in get_auth_backends(): + messages.error(self.request, _('Users need to have a pretix account before they can be invited.')) + return self.get(request, *args, **kwargs) invite = self.object.invites.create(email=self.add_form.cleaned_data['user']) self._send_invite(invite) diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index a6d13423db..120ca10644 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -23,6 +23,8 @@ from django.views.generic import FormView, ListView, TemplateView, UpdateView from django_otp.plugins.otp_static.models import StaticDevice from django_otp.plugins.otp_totp.models import TOTPDevice +from pretix.base.auth import get_auth_backends +from pretix.base.forms.auth import ReauthForm from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm from pretix.base.models import ( Event, LogEntry, NotificationSetting, U2FDevice, User, WebAuthnDevice, @@ -54,13 +56,13 @@ class ReauthView(TemplateView): template_name = 'pretixcontrol/user/reauth.html' def post(self, request, *args, **kwargs): - password = request.POST.get("password", "") + r = request.POST.get("webauthn", "") valid = False - if 'webauthn_challenge' in self.request.session and password.startswith('{'): + if 'webauthn_challenge' in self.request.session and r.startswith('{'): challenge = self.request.session['webauthn_challenge'] - resp = json.loads(password) + resp = json.loads(r) try: devices = [WebAuthnDevice.objects.get(user=self.request.user, credential_id=resp.get("id"))] except WebAuthnDevice.DoesNotExist: @@ -93,7 +95,7 @@ class ReauthView(TemplateView): valid = True break - valid = valid or request.user.check_password(password) + valid = valid or self.form.is_valid() if valid: t = int(time.time()) @@ -106,6 +108,14 @@ class ReauthView(TemplateView): messages.error(request, _('The password you entered was invalid, please try again.')) return self.get(request, *args, **kwargs) + def get(self, request, *args, **kwargs): + u = get_auth_backends()[request.user.auth_backend].request_authenticate(request) + if u and u == request.user: + if "next" in request.GET and is_safe_url(request.GET.get("next"), allowed_hosts=None): + return redirect(request.GET.get("next")) + return redirect(reverse('control:index')) + return super().get(request, *args, **kwargs) + def get_context_data(self, **kwargs): ctx = super().get_context_data() if 'webauthn_challenge' in self.request.session: @@ -127,8 +137,21 @@ class ReauthView(TemplateView): 'appid': get_u2f_appid(self.request) } ctx['jsondata'] = json.dumps(ad) + ctx['form'] = self.form return ctx + @cached_property + def form(self): + return ReauthForm( + user=self.request.user, + backend=get_auth_backends()[self.request.user.auth_backend], + request=self.request, + data=self.request.POST if self.request.method == "POST" else None, + initial={ + 'email': self.request.user.email, + } + ) + class UserSettings(UpdateView): model = User diff --git a/src/pretix/control/views/users.py b/src/pretix/control/views/users.py index 922d136c70..618de77776 100644 --- a/src/pretix/control/views/users.py +++ b/src/pretix/control/views/users.py @@ -11,6 +11,7 @@ from django.views import View from django.views.generic import ListView, TemplateView from hijack.helpers import login_user, release_hijack +from pretix.base.auth import get_auth_backends from pretix.base.models import User from pretix.base.services.mail import SendMailException from pretix.control.forms.filter import UserFilterForm @@ -52,6 +53,10 @@ class UserEditView(AdministratorPermissionRequiredMixin, RecentAuthenticationReq def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['teams'] = self.object.teams.select_related('organizer') + b = get_auth_backends() + ctx['backend'] = ( + b[self.object.auth_backend].verbose_name if self.object.auth_backend in b else self.object.auth_backend + ) return ctx def get_success_url(self): diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 7ed9b48b4a..4dd418060f 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -61,6 +61,8 @@ DEBUG = config.getboolean('django', 'debug', fallback=debug_fallback) PDFTK = config.get('tools', 'pdftk', fallback=None) +PRETIX_AUTH_BACKENDS = config.get('pretix', 'auth_backends', fallback='pretix.base.auth.NativeAuthBackend').split(',') + db_backend = config.get('database', 'backend', fallback='sqlite3') if db_backend == 'postgresql_psycopg2': db_backend = 'postgresql' diff --git a/src/pretix/static/pretixcontrol/js/ui/focus.js b/src/pretix/static/pretixcontrol/js/ui/focus.js new file mode 100644 index 0000000000..f18b86f1af --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/ui/focus.js @@ -0,0 +1,3 @@ +$(function () { + $("input, select, textarea").not(":disabled").focus(); +}); diff --git a/src/pretix/static/pretixcontrol/js/ui/webauthn.js b/src/pretix/static/pretixcontrol/js/ui/webauthn.js index 5ec7c951bf..7b4094c451 100644 --- a/src/pretix/static/pretixcontrol/js/ui/webauthn.js +++ b/src/pretix/static/pretixcontrol/js/ui/webauthn.js @@ -163,6 +163,7 @@ const startLogin = async (e) => { const transformedAssertionForServer = transformAssertionForServer(assertion); // post the assertion to the server for verification. + $("input, select, textarea").prop("required", false); $("#webauthn-response, #id_password").val(JSON.stringify(transformedAssertionForServer)); $("#webauthn-form").submit(); }; diff --git a/src/pretix/testutils/settings.py b/src/pretix/testutils/settings.py index bf3dde10e3..263acc0648 100644 --- a/src/pretix/testutils/settings.py +++ b/src/pretix/testutils/settings.py @@ -32,6 +32,10 @@ TEMPLATES[0]['OPTIONS']['loaders'] = ( DEBUG = True DEBUG_PROPAGATE_EXCEPTIONS = True +PRETIX_AUTH_BACKENDS = [ + 'pretix.base.auth.NativeAuthBackend', +] + PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] # Disable celery diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index 3e28bba1d7..cf3fc80070 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -103,6 +103,51 @@ class LoginFormTest(TestCase): response = self.client.get('/control/login') self.assertEqual(response.status_code, 200) + def test_wrong_backend(self): + self.user = User.objects.create_user('hallo@example.com', 'dummy', auth_backend='test_request') + response = self.client.post('/control/login', { + 'email': 'hallo@example.com', + 'password': 'dummy', + }) + self.assertEqual(response.status_code, 200) + + def test_backends_shown(self): + response = self.client.get('/control/login') + self.assertEqual(response.status_code, 200) + assert b'Form' in response.content + assert b'pretix User' in response.content + assert b'Request' not in response.content + + def test_form_backend(self): + response = self.client.get('/control/login?backend=test_form') + self.assertEqual(response.status_code, 200) + assert b'name="username"' in response.content + + response = self.client.post('/control/login?backend=test_form', { + 'username': 'dummy', + 'password': 'dummy', + }) + self.assertEqual(response.status_code, 200) + assert b'alert-danger' in response.content + + response = self.client.post('/control/login?backend=test_form', { + 'username': 'foo', + 'password': 'bar', + }) + self.assertEqual(response.status_code, 302) + response = self.client.get('/control/') + assert b'foo' in response.content + + def test_request_backend(self): + response = self.client.get('/control/login?backend=test_request') + self.assertEqual(response.status_code, 200) + assert b'name="email"' in response.content + + response = self.client.get('/control/login', HTTP_X_LOGIN_EMAIL='hallo@example.org') + self.assertEqual(response.status_code, 302) + response = self.client.get('/control/') + assert b'hallo@example.org' in response.content + class RegistrationFormTest(TestCase): @@ -201,6 +246,24 @@ class RegistrationFormTest(TestCase): assert time.time() - self.client.session['pretix_auth_login_time'] < 60 assert not self.client.session['pretix_auth_long_session'] + @override_settings(PRETIX_REGISTRATION=False) + def test_disabled(self): + response = self.client.post('/control/register', { + 'email': 'dummy@dummy.dummy', + 'password': 'foobarbar', + 'password_repeat': 'foobarbar' + }) + self.assertEqual(response.status_code, 403) + + @override_settings(PRETIX_AUTH_BACKENDS=['tests.testdummy.auth.TestFormAuthBackend']) + def test_no_native_auth(self): + response = self.client.post('/control/register', { + 'email': 'dummy@dummy.dummy', + 'password': 'foobarbar', + 'password_repeat': 'foobarbar' + }) + self.assertEqual(response.status_code, 403) + @pytest.fixture def class_monkeypatch(request, monkeypatch): @@ -560,6 +623,20 @@ class PasswordRecoveryFormTest(TestCase): self.user = User.objects.get(id=self.user.id) self.assertTrue(self.user.check_password('demo')) + @override_settings(PRETIX_PASSWORD_RESET=False) + def test_disabled(self): + response = self.client.post('/control/forgot', { + 'email': 'dummy@dummy.dummy', + }) + self.assertEqual(response.status_code, 403) + + @override_settings(PRETIX_AUTH_BACKENDS=['tests.testdummy.auth.TestFormAuthBackend']) + def test_no_native_auth(self): + response = self.client.post('/control/forgot', { + 'email': 'dummy@dummy.dummy', + }) + self.assertEqual(response.status_code, 403) + class SessionTimeOutTest(TestCase): def setUp(self): diff --git a/src/tests/control/test_user.py b/src/tests/control/test_user.py index 0a3ddbd706..086fc5a849 100644 --- a/src/tests/control/test_user.py +++ b/src/tests/control/test_user.py @@ -72,6 +72,18 @@ class UserSettingsTest(SoupTest): self.user = User.objects.get(pk=self.user.pk) assert self.user.password == pw + def test_change_password_wrong_backend(self): + self.user.auth_backend = 'test_request' + self.user.save() + self.save({ + 'new_pw': 'foobarbar', + 'new_pw_repeat': 'foobarbar', + 'old_pw': 'dummy', + }) + pw = self.user.password + self.user = User.objects.get(pk=self.user.pk) + assert self.user.password == pw + def test_change_password_success(self): doc = self.save({ 'new_pw': 'foobarbar', diff --git a/src/tests/settings.py b/src/tests/settings.py index 4a83b94c69..69aa9368a8 100644 --- a/src/tests/settings.py +++ b/src/tests/settings.py @@ -8,5 +8,11 @@ TEMPLATES[0]['DIRS'].append(os.path.join(TEST_DIR, 'templates')) # NOQA INSTALLED_APPS.append('tests.testdummy') # NOQA +PRETIX_AUTH_BACKENDS = [ + 'pretix.base.auth.NativeAuthBackend', + 'tests.testdummy.auth.TestFormAuthBackend', + 'tests.testdummy.auth.TestRequestAuthBackend', +] + for a in PLUGINS: - INSTALLED_APPS.remove(a) \ No newline at end of file + INSTALLED_APPS.remove(a) diff --git a/src/tests/testdummy/auth.py b/src/tests/testdummy/auth.py new file mode 100644 index 0000000000..639f1b8da1 --- /dev/null +++ b/src/tests/testdummy/auth.py @@ -0,0 +1,38 @@ +from collections import OrderedDict + +from django import forms + +from pretix.base.auth import BaseAuthBackend +from pretix.base.models import User + + +class TestFormAuthBackend(BaseAuthBackend): + identifier = 'test_form' + verbose_name = 'Form' + + @property + def login_form_fields(self) -> dict: + return OrderedDict([ + ('username', forms.CharField(max_length=100)), + ('password', forms.CharField(max_length=100)), + ]) + + def form_authenticate(self, request, form_data): + if form_data['username'] == 'foo' and form_data['password'] == 'bar': + return User.objects.get_or_create( + email='foo@example.com', + auth_backend='test_form' + )[0] + + +class TestRequestAuthBackend(BaseAuthBackend): + identifier = 'test_request' + verbose_name = 'Request' + visible = False + + def request_authenticate(self, request): + if 'X-Login-Email' in request.headers: + return User.objects.get_or_create( + email=request.headers['X-Login-Email'], + auth_backend='test_request' + )[0]