Auth mechanism

This commit is contained in:
Raphael Michel
2018-09-19 12:01:16 +02:00
parent ddb645aeea
commit 6d01c99d38
11 changed files with 239 additions and 9 deletions

View File

@@ -0,0 +1,25 @@
from django.contrib.auth.models import AnonymousUser
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
from pretix.base.models import Device
class DeviceTokenAuthentication(TokenAuthentication):
model = Device
keyword = 'Device'
def authenticate_credentials(self, key):
model = self.get_model()
try:
device = model.objects.select_related('organizer').get(api_token=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.')
if not device.initialized:
raise exceptions.AuthenticationFailed('Device has not been initialized.')
if not device.api_token:
raise exceptions.AuthenticationFailed('Device access has been revoked.')
return AnonymousUser(), device

View File

@@ -1,7 +1,7 @@
from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken
from pretix.base.models import Event
from pretix.base.models import Device, Event
from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
@@ -9,10 +9,9 @@ from pretix.helpers.security import (
class EventPermission(BasePermission):
model = TeamAPIToken
def has_permission(self, request, view):
if not request.user.is_authenticated and not isinstance(request.auth, TeamAPIToken):
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
return False
if request.method not in SAFE_METHODS and hasattr(view, 'write_permission'):
@@ -31,7 +30,7 @@ class EventPermission(BasePermission):
except SessionReauthRequired:
return False
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken)
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
else request.user)
if 'event' in request.resolver_match.kwargs and 'organizer' in request.resolver_match.kwargs:
request.event = Event.objects.filter(

View File

@@ -7,7 +7,8 @@ from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, event, item, oauth, order, organizer, voucher, waitinglist,
checkin, device, event, item, oauth, order, organizer, voucher,
waitinglist,
)
router = routers.DefaultRouter()
@@ -66,4 +67,5 @@ urlpatterns = [
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),
url(r"^device/initialize", device.InitializeView.as_view(), name="device.initialize"),
]

View File

@@ -0,0 +1,59 @@
import logging
from django.utils.timezone import now
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix.base.models import Device
from pretix.base.models.devices import generate_api_token
logger = logging.getLogger(__name__)
class InitializationRequestSerializer(serializers.Serializer):
token = serializers.CharField(max_length=190)
hardware_brand = serializers.CharField(max_length=190)
hardware_model = serializers.CharField(max_length=190)
software_brand = serializers.CharField(max_length=190)
software_version = serializers.CharField(max_length=190)
class DeviceSerializer(serializers.ModelSerializer):
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
class Meta:
model = Device
fields = [
'organizer', 'device_id', 'unique_serial', 'api_token',
'name'
]
class InitializeView(APIView):
authentication_classes = tuple()
permission_classes = tuple()
def post(self, request, format=None):
serializer = InitializationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
device = Device.objects.get(initialization_token=serializer.validated_data.get('token'))
except Device.DoesNotExist:
raise ValidationError({'token': ['Unknown initialization token.']})
if device.initialized:
raise ValidationError({'token': ['This initialization token has already been used.']})
device.initialized = now()
device.hardware_brand = serializer.validated_data.get('hardware_brand')
device.hardware_model = serializer.validated_data.get('hardware_model')
device.software_brand = serializer.validated_data.get('software_brand')
device.software_version = serializer.validated_data.get('software_version')
device.api_token = generate_api_token()
device.save()
serializer = DeviceSerializer(device)
return Response(serializer.data)

View File

@@ -5,6 +5,8 @@ from django.db.models import Max
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import LoggedModel
def generate_serial():
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
@@ -22,12 +24,12 @@ def generate_initialization_token():
def generate_api_token():
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(initialization_token=token).exists():
while Device.objects.filter(api_token=token).exists():
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
return token
class Device(models.Model):
class Device(LoggedModel):
organizer = models.ForeignKey(
'pretixbase.Organizer',
on_delete=models.PROTECT,
@@ -80,3 +82,75 @@ class Device(models.Model):
if not self.device_id:
self.device_id = (self.organizer.devices.aggregate(m=Max('device_id'))['m'] or 0) + 1
super().save(*args, **kwargs)
def permission_set(self) -> set:
return {
'can_view_orders',
'can_change_orders',
'can_view_products'
}
def get_event_permission_set(self, organizer, event) -> set:
"""
Gets a set of permissions (as strings) that a token holds for a particular event
:param organizer: The organizer of the event
:param event: The event to check
:return: set of permissions
"""
has_event_access = (self.all_events and organizer == self.organizer) or (
event in self.limit_events.all()
)
return self.permission_set() if has_event_access else set()
def get_organizer_permission_set(self, organizer) -> set:
"""
Gets a set of permissions (as strings) that a token holds for a particular organizer
:param organizer: The organizer of the event
:return: set of permissions
"""
return self.permission_set() if self.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``.
:param organizer: The organizer of the event
:param event: The event to check
:param perm_name: The permission, e.g. ``can_change_teams``
:param request: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
has_event_access = (self.all_events and organizer == self.organizer) or (
event in self.limit_events.all()
)
if isinstance(perm_name, (tuple, list)):
return has_event_access and any(p in self.permission_set() for p in perm_name)
return has_event_access and (not perm_name or perm_name in self.permission_set())
def has_organizer_permission(self, organizer, perm_name=None, request=None):
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the organizer ``organizer``.
:param organizer: The organizer to check
:param perm_name: The permission, e.g. ``can_change_teams``
:param request: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
if isinstance(perm_name, (tuple, list)):
return organizer == self.organizer and any(p in self.permission_set() for p in perm_name)
return organizer == self.organizer and (not perm_name or perm_name in self.permission_set())
def get_events_with_any_permission(self):
"""
Returns a queryset of events the token has any permissions to.
:return: Iterable of Events
"""
if self.all_events:
return self.organizer.events.all()
else:
return self.limit_events.all()

View File

@@ -276,6 +276,18 @@ class Event(EventMixin, LoggedModel):
else:
return super().presale_has_ended
def delete_all_orders(self, really=False):
from .orders import OrderRefund, OrderPayment, OrderPosition, OrderFee
if not really:
raise TypeError("Pass really=True as a parameter.")
OrderPosition.objects.all().delete(order__event=self)
OrderFee.objects.all().delete(order__event=self)
OrderPayment.objects.all().delete(order__event=self)
OrderRefund.objects.all().delete(order__event=self)
self.orders.all().delete()
def save(self, *args, **kwargs):
obj = super().save(*args, **kwargs)
self.cache.clear()

View File

@@ -1,7 +1,25 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load staticfiles %}
{% load bootstrap3 %}
{% block inner %}
<legend>{% trans "Connect to device:" %} {{ device.name }}</legend>
<div>
<ol>
<li>{% trans "Open the app that you want to connect and optionally reset it to the original state." %}</li>
<li>{% trans "Scan the following configuration code:" %}<br><br>
<script type="text/json" data-replace-with-qr>{{ qrdata|safe }}</script><br>
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
<br>
<strong>{% trans "System URL:" %}</strong> {{ settings.SITE_URL }}<br>
<strong>{% trans "Token:" %}</strong> {{ device.initialization_token }}
</li>
</ol>
</div>
<a href="{% url "control:organizer.devices" organizer=request.organizer.slug %}"
class="btn btn-default"><i class="fa fa-arrow-left"></i>
{% trans "Device overview" %}
</a>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/devices.js" %}"></script>
{% endblock %}

View File

@@ -1,10 +1,14 @@
import json
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.db import transaction
from django.db.models import Count
from django.forms import inlineformset_factory
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
@@ -608,7 +612,6 @@ class DeviceCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def form_valid(self, form):
form.instance.organizer = self.request.organizer
ret = super().form_valid(form)
form.instance.members.add(self.request.user)
form.instance.log_action('pretix.device.created', user=self.request.user, data={
k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
for k in form.changed_data
@@ -666,9 +669,22 @@ class DeviceConnectView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMix
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if 'ajax' in request.GET:
return JsonResponse({
'initialized': bool(self.object.initialized)
})
if self.object.initialized:
messages.error(request, _('This device already has been connected.'))
messages.success(request, _('This device has been set up successfully.'))
return redirect(reverse('control:organizer.devices', kwargs={
'organizer': self.request.organizer.slug,
}))
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['qrdata'] = json.dumps({
'handshake_version': 1,
'url': settings.SITE_URL,
'token': self.object.initialization_token,
})
return ctx

View File

@@ -278,6 +278,7 @@ REST_FRAMEWORK = {
'PAGE_SIZE': 50,
'DEFAULT_AUTHENTICATION_CLASSES': (
'pretix.api.auth.token.TeamTokenAuthentication',
'pretix.api.auth.device.DeviceTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
),

View File

@@ -0,0 +1,14 @@
/*globals $, Morris, gettext, RRule, RRuleSet*/
$(function () {
var update = function () {
$.getJSON(location.href + '?ajax=true', {}, function (data) {
if (data.initialized) {
location.reload();
} else {
window.setTimeout(update, 500);
}
});
};
window.setTimeout(update, 500);
});

View File

@@ -366,6 +366,16 @@ var form_handlers = function (el) {
el.find("input[name=basics-slug]").bind("keyup keydown change", function () {
$(this).closest(".form-group").find(".slug-length").toggle($(this).val().length > 16);
});
el.find("script[data-replace-with-qr]").each(function () {
var $div = $("<div>");
$div.insertBefore($(this));
$div.qrcode(
{
text: $(this).html()
}
);
});
};
$(function () {