Refs #775 -- Pluggable authentication backends (#1447)

* Drag-and-drop: Force csrf_token to be present

* Rough design

* Missing file

* b.visble

* Forms

* Docs

* Tests

* Fix variable
This commit is contained in:
Raphael Michel
2019-10-17 09:11:03 +02:00
committed by GitHub
parent e34511b984
commit 8a6a515b6a
25 changed files with 476 additions and 72 deletions

109
src/pretix/base/auth.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,28 +2,40 @@
{% load bootstrap3 %}
{% load i18n %}
{% load static %}
{% load urlreplace %}
{% block content %}
<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 %}
{% if form.keep_logged_in %}
{% bootstrap_field form.keep_logged_in %}
{% if backends|length > 1 %}
<ul class="nav nav-pills">
{% for b in backends %}
{% if b.visible %}
<li class="{% if backend.identifier == b.identifier %}active{% endif %}">
<a href="{% if b.url %}{{ b.url }}{% else %}?{% url_replace request "backend" b.identifier %}{% endif %}">
{{ b.verbose_name }}
</a>
</li>
{% endif %}
{% endfor %}
</ul>
<br>
{% endif %}
{% csrf_token %}
{% bootstrap_form form %}
<div class="form-group buttons">
<button type="submit" class="btn btn-primary btn-block">
{% trans "Log in" %}
</button>
{% if can_reset %}
<a href="{% url "control:auth.forgot" %}" class="btn btn-link btn-block">
{% trans "Lost password?" %}
</a>
{% endif %}
{% if can_register %}
<a href="{% url "control:auth.register" %}" class="btn btn-link btn-block">
{% trans "Register" %}
</a>
{% if backend.identifier == "native" %}
{% if can_reset %}
<a href="{% url "control:auth.forgot" %}" class="btn btn-link btn-block">
{% trans "Lost password?" %}
</a>
{% endif %}
{% if can_register %}
<a href="{% url "control:auth.register" %}" class="btn btn-link btn-block">
{% trans "Register" %}
</a>
{% endif %}
{% endif %}
</div>
</form>

View File

@@ -10,14 +10,9 @@
<p>
{% trans "We just want to make sure it's really you. Please re-enter your password to continue." %}
</p>
<div class="form-group">
<input class="form-control"
value="{{ request.user.get_full_name }}" disabled>
</div>
<div class="form-group">
<input class="form-control" id="id_password" name="password" placeholder="{% trans "Password" %}"
title="" type="password" required="" autofocus>
</div>
{% bootstrap_form form %}
<input class="form-control" id="webauthn-response" name="webauthn"
type="hidden">
{% if jsondata %}
<div class="sr-only alert alert-danger" id="webauthn-error">
{% trans "WebAuthn failed. Check that the correct authentication device is correctly plugged in." %}
@@ -45,6 +40,7 @@
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/base64js.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/webauthn.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/focus.js" %}"></script>
{% endcompress %}
</form>
{% endblock %}

View File

@@ -33,10 +33,14 @@
</fieldset>
<fieldset>
<legend>{% trans "Login settings" %}</legend>
{% 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 %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Two-factor authentication" %}</label>
<div class="col-md-9 static-form-row">

View File

@@ -5,10 +5,12 @@
{% block content %}
<h1>{% trans "User" %} {{ user.email }}</h1>
<p>
<form action="{% url "control:users.reset" id=user.pk %}" method="post" class="form-inline helper-display-inline">
{% csrf_token %}
<button class="btn btn-default">{% trans "Send password reset email" %}</button>
</form>
{% if user.auth_backend == "native" %}
<form action="{% url "control:users.reset" id=user.pk %}" method="post" class="form-inline helper-display-inline">
{% csrf_token %}
<button class="btn btn-default">{% trans "Send password reset email" %}</button>
</form>
{% endif %}
<form action="{% url "control:users.impersonate" id=user.pk %}" method="post" class="form-inline helper-display-inline">
{% csrf_token %}
<button class="btn btn-default">{% trans "Impersonate user" %}</button>
@@ -30,9 +32,17 @@
</fieldset>
<fieldset>
<legend>{% trans "Log-in settings" %}</legend>
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Authentication backend" %}</label>
<div class="col-md-9">
<input name="text" value="{{ backend }}" class="form-control" disabled>
</div>
</div>
{% 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' %}
</fieldset>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
$(function () {
$("input, select, textarea").not(":disabled").focus();
});

View File

@@ -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();
};

View File

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