Implement OAuth2 provider (#927)

- [x] Application management
  - [x] Link
  - [ ] Tests
- [x] Authorize flow
  - [x] Tests
- [x] Refresh token handling
  - [x] Tests
- [x] Revocation endpoint
  - [x] Tests
  - [x] Mitigate: https://github.com/jazzband/django-oauth-toolkit/issues/585
- [x] API authenticator / permission driver
  - [x] Test
- [x] Enforce organizer restriction
  - [x] Tests
- [x] Enforce scope restriction
  - [x] Tests
- [x] Show current applications to user
  - [x] Revoke
  - [x] Tests
- [x] Log new authorizations
  - [x] notify user
- [x] Ensure other grant types are not available
- [x] Documentation
- [x] check if revoking access toking, then refreshing gets rid of organizer constraint
- [x] Show logentry foo
This commit is contained in:
Raphael Michel
2018-06-05 12:58:04 +02:00
committed by GitHub
parent df031b2222
commit 69d10489b8
53 changed files with 1786 additions and 116 deletions

View File

@@ -197,6 +197,8 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.user.settings.notifications.enabled': _('Notifications have been enabled.'),
'pretix.user.settings.notifications.disabled': _('Notifications have been disabled.'),
'pretix.user.settings.notifications.changed': _('Your notification settings have been changed.'),
'pretix.user.oauth.authorized': _('The application "{application_name}" has been authorized to access your '
'account.'),
'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'),
'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'),
'pretix.voucher.added': _('The voucher has been created.'),

View File

@@ -0,0 +1,51 @@
{% extends "pretixcontrol/auth/base.html" %}
{% load bootstrap3 %}
{% load staticfiles %}
{% load i18n %}
{% block content %}
{% if not error %}
<form class="form-signin" action="" method="post">
<h3>{% trans "Authorize an application" %}</h3>
{% csrf_token %}
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% endif %}
{% endfor %}
<p>
{% blocktrans trimmed with application=application.name %}
Do you really want to grant the application <strong>{{ application }}</strong> access to your
pretix account?
{% endblocktrans %}
</p>
<p>{% trans "The application requires the following permissions:" %}</p>
<ul>
{% for scope in scopes_descriptions %}
<li>{{ scope }}</li>
{% endfor %}
</ul>
<p>{% trans "Please select the organizer accounts this application should get access to:" %}</p>
{% bootstrap_field form.organizers layout="inline" %}
{% bootstrap_form_errors form layout="control" %}
<p class="text-danger">
{% blocktrans trimmed %}
This application has <strong>not</strong> been reviewed by the pretix team. Granting access to your
pretix account happens at your own risk.
{% endblocktrans %}
</p>
<div class="form-group buttons">
<input type="submit" class="btn btn-large btn-default" value="Cancel"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="Authorize"/>
</div>
</form>
{% else %}
<form class="form-signin" action="" method="post">
<h3>{% trans "Error:" %} {{ error.error }}</h3>
<p>{{ error.description }}</p>
</form>
{% endif %}
{% endblock %}

View File

@@ -1,4 +1,4 @@
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
this is to inform you that the account information of your pretix account has been
changed. In particular, the following changes have been performed:

View File

@@ -124,6 +124,13 @@
<span class="fa fa-user fa-fw"></span>
{% endif %}
{{ log.user.get_full_name }}
{% if log.oauth_application %}
<br><span class="fa fa-plug fa-fw"></span>
{{ log.oauth_application.name }}
{% endif %}
{% elif log.api_token %}
<span class="fa fa-key fa-fw"></span>
{{ log.api_token.name }}
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">

View File

@@ -16,7 +16,8 @@
</option>
{% for up in userlist %}
{% if up.user__id %}
<option value="{{ up.user__id }}" {% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
<option value="{{ up.user__id }}"
{% if request.GET.user == up.user__id %}selected="selected"{% endif %}>
{{ up.user__email }}
</option>
{% endif %}
@@ -42,13 +43,20 @@
{% if log.user %}
{% if log.user.is_staff %}
<span class="fa fa-id-card fa-danger fa-fw"
data-toggle="tooltip"
title="{% trans "This change was performed by a pretix administrator." %}">
data-toggle="tooltip"
title="{% trans "This change was performed by a pretix administrator." %}">
</span>
{% else %}
<span class="fa fa-user fa-fw"></span>
{% endif %}
{{ log.user.get_full_name }}
{% if log.oauth_application %}
<br><span class="fa fa-plug fa-fw"></span>
{{ log.oauth_application.name }}
{% endif %}
{% elif log.api_token %}
<span class="fa fa-key fa-fw"></span>
{{ log.api_token.name }}
{% endif %}
</div>
<div class="col-lg-2 col-sm-12 col-xs-12">
@@ -61,7 +69,7 @@
</div>
</div>
</li>
{% empty %}
{% empty %}
<div class="list-group-item">
<em>{% trans "No results" %}</em>
</div>

View File

@@ -15,6 +15,13 @@
<span class="fa fa-user fa-fw"></span>
{% endif %}
{{ log.user.get_full_name }}
{% if log.oauth_application %}
<span class="fa fa-plug fa-fw"></span>
{{ log.oauth_application.name }}
{% endif %}
{% elif log.api_token %}
<span class="fa fa-key fa-fw"></span>
{{ log.api_token.name }}
{% endif %}
{% if log.shredded %}
<span class="fa fa-eraser fa-danger fa-fw"

View File

@@ -0,0 +1,19 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Disable application" %}{% endblock %}
{% block content %}
<h1>{% trans "Disable application" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to disable the application <strong>{{ application }}</strong> permanently?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:user.settings.oauth.apps" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Disable" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Your applications" %}{% endblock %}
{% block content %}
<h1>{% trans "Your applications" %}</h1>
{% if applications %}
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for application in applications %}
<tr>
<td><strong><a href="{% url "control:user.settings.oauth.app" pk=application.pk %}">{{ application.name }}</a></strong></td>
<td class="text-right">
<a href="{% url "control:user.settings.oauth.app" pk=application.pk %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:user.settings.oauth.app.roll" pk=application.pk %}" class="btn btn-default btn-sm"><i class="fa fa-repeat"></i></a>
<a href="{% url "control:user.settings.oauth.app.disable" pk=application.pk %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p>
<a class="btn btn-primary" href="{% url "control:user.settings.oauth.apps.register" %}">
<span class="fa fa-plus"></span>
{% trans "Create new application" %}
</a>
</p>
{% else %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No applications registered yet.
{% endblocktrans %}
</p>
<a href="{% url "control:user.settings.oauth.apps.register" %}"
class="btn btn-primary btn-lg">
{% trans "Register a new application" %}
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Register a new application" %}{% endblock %}
{% block content %}
<h1>{% trans "Register a new application" %}</h1>
<form class="form-horizontal" method="post" action="">
{% csrf_token %}
{% bootstrap_form form layout='control' %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Generate new application secret" %}{% endblock %}
{% block content %}
<h1>{% trans "Generate new application secret" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to generate a new client secret for the application <strong>{{ application }}</strong>?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:user.settings.oauth.apps" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Roll secret" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Update an application" %}{% endblock %}
{% block content %}
<h1>{% trans "Update an application" %}</h1>
<form class="form-horizontal" method="post" action="">
{% csrf_token %}
{% bootstrap_form form layout='control' %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Revoke access" %}{% endblock %}
{% block content %}
<h1>{% trans "Revoke access" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to revoke access to your account for the application <strong>{{ application }}</strong>?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:user.settings.oauth.list" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Revoke" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Authorized applications" %}{% endblock %}
{% block content %}
<h1>{% trans "Authorized applications" %}</h1>
<p>
<a href="{% url "control:user.settings.oauth.apps" %}" class="btn btn-default">
{% trans "Manage your own apps" %}
</a>
</p>
{% if tokens %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Permissions" %}</th>
<th>{% trans "Organizers" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td><strong>{{ token.application.name }}</strong></td>
<td>
<ul>
{% for scope in token.scopes_descriptions %}
<li>
{{ scope }}
</li>
{% endfor %}
</ul>
</td>
<td>
<ul>
{% for o in token.organizers.all %}
<li>
<a href="{% url "control:organizer" organizer=o.slug %}">
{{ o.name }}
</a>
</li>
{% endfor %}
</ul>
</td>
<td class="text-right">
<a href="{% url "control:user.settings.oauth.revoke" pk=token.pk %}"
class="btn btn-danger btn-sm">{% trans "Revoke access" %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No applications have access to your pretix account.
{% endblocktrans %}
</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -54,7 +54,16 @@
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label" for="id_new_pw_repeat">{% trans "Account history" %}</label>
<label class="col-md-3 control-label" for="">{% trans "Authorized applications" %}</label>
<div class="col-md-9 static-form-row">
<a href="{% url "control:user.settings.oauth.list" %}">
<span class="fa fa-plug"></span>
{% trans "Show applications" %}
</a>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label" for="">{% trans "Account history" %}</label>
<div class="col-md-9 static-form-row">
<a href="{% url "control:user.settings.history" %}">
<span class="fa fa-history"></span>

View File

@@ -1,9 +1,9 @@
from django.conf.urls import include, url
from pretix.control.views import (
auth, checkin, dashboards, event, global_settings, item, main, orders,
organizer, pdf, search, shredder, subevents, typeahead, user, users,
vouchers, waitinglist,
auth, checkin, dashboards, event, global_settings, item, main, oauth,
orders, organizer, pdf, search, shredder, subevents, typeahead, user,
users, vouchers, waitinglist,
)
urlpatterns = [
@@ -35,6 +35,20 @@ urlpatterns = [
url(r'^settings/notifications/$', user.UserNotificationsEditView.as_view(), name='user.settings.notifications'),
url(r'^settings/notifications/off/(?P<id>\d+)/(?P<token>[^/]+)/$', user.UserNotificationsDisableView.as_view(),
name='user.settings.notifications.off'),
url(r'^settings/oauth/authorized/$', oauth.AuthorizationListView.as_view(),
name='user.settings.oauth.list'),
url(r'^settings/oauth/authorized/(?P<pk>\d+)/revoke$', oauth.AuthorizationRevokeView.as_view(),
name='user.settings.oauth.revoke'),
url(r'^settings/oauth/apps/$', oauth.OAuthApplicationListView.as_view(),
name='user.settings.oauth.apps'),
url(r'^settings/oauth/apps/add$', oauth.OAuthApplicationRegistrationView.as_view(),
name='user.settings.oauth.apps.register'),
url(r'^settings/oauth/apps/(?P<pk>\d+)/$', oauth.OAuthApplicationUpdateView.as_view(),
name='user.settings.oauth.app'),
url(r'^settings/oauth/apps/(?P<pk>\d+)/disable$', oauth.OAuthApplicationDeleteView.as_view(),
name='user.settings.oauth.app.disable'),
url(r'^settings/oauth/apps/(?P<pk>\d+)/roll$', oauth.OAuthApplicationRollView.as_view(),
name='user.settings.oauth.app.roll'),
url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
url(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
url(r'^settings/2fa/enable', user.User2FAEnableView.as_view(), name='user.settings.2fa.enable'),

View File

@@ -250,7 +250,7 @@ def event_index(request, organizer, event):
can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders',
request=request)
qs = request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime')
qs = request.event.logentry_set.all().select_related('user', 'content_type', 'api_token', 'oauth_application').order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST)
if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request):
qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order))

View File

@@ -347,6 +347,7 @@ class EventSettingsFormView(EventPermissionRequiredMixin, FormView):
for k in form.changed_data
}
)
self.form_success()
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
@@ -824,7 +825,9 @@ class EventLog(EventPermissionRequiredMixin, ListView):
paginate_by = 20
def get_queryset(self):
qs = self.request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime')
qs = self.request.event.logentry_set.all().select_related(
'user', 'content_type', 'api_token', 'oauth_application'
).order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST)
if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_orders',
request=self.request):

View File

@@ -0,0 +1,140 @@
import logging
from django import forms
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from django.views.generic import DetailView, ListView
from oauth2_provider.generators import generate_client_secret
from oauth2_provider.models import get_application_model
from oauth2_provider.scopes import get_scopes_backend
from oauth2_provider.views import (
ApplicationDelete, ApplicationDetail, ApplicationList,
ApplicationRegistration, ApplicationUpdate,
)
from pretix.api.models import (
OAuthAccessToken, OAuthApplication, OAuthRefreshToken,
)
logger = logging.getLogger(__name__)
class OAuthApplicationListView(ApplicationList):
template_name = 'pretixcontrol/oauth/app_list.html'
def get_queryset(self):
return super().get_queryset().filter(active=True)
class OAuthApplicationRegistrationView(ApplicationRegistration):
template_name = 'pretixcontrol/oauth/app_register.html'
def get_form_class(self):
return forms.modelform_factory(
get_application_model(),
fields=(
"name", "redirect_uris"
)
)
def form_valid(self, form):
form.instance.client_type = 'confidential'
form.instance.authorization_grant_type = 'authorization-code'
return super().form_valid(form)
class ApplicationUpdateForm(forms.ModelForm):
class Meta:
model = OAuthApplication
fields = ("name", "client_id", "client_secret", "redirect_uris")
def clean_client_id(self):
return self.instance.client_id
def clean_client_secret(self):
return self.instance.client_secret
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['client_id'].widget.attrs['readonly'] = True
self.fields['client_secret'].widget.attrs['readonly'] = True
class OAuthApplicationUpdateView(ApplicationUpdate):
template_name = 'pretixcontrol/oauth/app_update.html'
def get_form_class(self):
return ApplicationUpdateForm
def get_queryset(self):
return super().get_queryset().filter(active=True)
class OAuthApplicationRollView(ApplicationDetail):
template_name = 'pretixcontrol/oauth/app_rollkeys.html'
def post(self, request, *args, **kwargs):
self.object = self.get_object()
messages.success(request, _('A new client secret has been generated and is now effective.'))
self.object.client_secret = generate_client_secret()
self.object.save()
return HttpResponseRedirect(self.object.get_absolute_url())
def get_queryset(self):
return super().get_queryset().filter(active=True)
class OAuthApplicationDeleteView(ApplicationDelete):
template_name = 'pretixcontrol/oauth/app_delete.html'
success_url = reverse_lazy("control:user.settings.oauth.apps")
def get_queryset(self):
return super().get_queryset().filter(active=True)
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
self.object.active = False
self.object.save()
return HttpResponseRedirect(self.success_url)
class AuthorizationListView(ListView):
template_name = 'pretixcontrol/oauth/authorized.html'
context_object_name = 'tokens'
def get_queryset(self):
return OAuthAccessToken.objects.filter(
user=self.request.user
).select_related('application').prefetch_related('organizers')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
all_scopes = get_scopes_backend().get_all_scopes()
for t in ctx['tokens']:
t.scopes_descriptions = [all_scopes[scope] for scope in t.scopes]
return ctx
class AuthorizationRevokeView(DetailView):
template_name = 'pretixcontrol/oauth/auth_revoke.html'
success_url = reverse_lazy("control:user.settings.oauth.list")
def get_queryset(self):
return OAuthAccessToken.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['application'] = self.get_object().application
return ctx
def post(self, request, *args, **kwargs):
o = self.get_object()
for rt in OAuthRefreshToken.objects.filter(access_token=o):
rt.revoke()
o.delete()
messages.success(request, _('Access for the selected application has been revoked.'))
return redirect(self.success_url)