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

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