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 %}
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." %}
-