diff --git a/core/authentication/admin.py b/core/authentication/admin.py new file mode 100644 index 0000000..f972c82 --- /dev/null +++ b/core/authentication/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType + +from authentication.models import ExtendedUser, EventPermission, ExtendedAuthToken, AuthTokenEventPermissions + + +class ExtendedUserAdmin(UserAdmin): + list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_superuser') + search_fields = ('username', 'email', 'first_name', 'last_name') + ordering = ('username',) + filter_horizontal = ('groups', 'user_permissions', 'permissions') + + def permissions(self, obj): + return ', '.join(obj.get_all_permissions()) + + +admin.site.register(ExtendedUser, ExtendedUserAdmin) diff --git a/core/authentication/api_v2.py b/core/authentication/api_v2.py index b9a67cb..019a0fd 100644 --- a/core/authentication/api_v2.py +++ b/core/authentication/api_v2.py @@ -1,5 +1,7 @@ from rest_framework import routers, viewsets, serializers, permissions from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.authtoken.serializers import AuthTokenSerializer +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from django.contrib.auth import login from django.urls import path @@ -7,7 +9,6 @@ from django.dispatch import receiver from django.db.models.signals import post_save from knox.models import AuthToken from knox.views import LoginView as KnoxLoginView -from rest_framework.authtoken.serializers import AuthTokenSerializer from authentication.models import ExtendedUser @@ -30,6 +31,7 @@ class UserViewSet(viewsets.ModelViewSet): @api_view(['POST']) +@permission_classes([IsAuthenticated]) def selfUser(request): serializer = UserSerializer(request.user) return Response(serializer.data, status=200) diff --git a/core/authentication/tests/v2/test_permissions.py b/core/authentication/tests/v2/test_permissions.py index 89f217d..bb184cf 100644 --- a/core/authentication/tests/v2/test_permissions.py +++ b/core/authentication/tests/v2/test_permissions.py @@ -10,6 +10,7 @@ class PermissionsTestCase(TestCase): def setUp(self): super().setUp() self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) event1 = Event.objects.create(slug='testevent1', name='testevent1') event2 = Event.objects.create(slug='testevent2', name='testevent2') permission1 = Permission.objects.get(codename='view_event') @@ -17,6 +18,9 @@ class PermissionsTestCase(TestCase): EventPermission.objects.create(user=self.user, permission=permission1, event=event2) self.token = AuthToken.objects.create(user=self.user) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + self.newuser = ExtendedUser.objects.create_user('newuser', 'test', 'test') + self.newuser_token = AuthToken.objects.create(user=self.newuser) + self.newuser_client = Client(headers={'Authorization': 'Token ' + self.newuser_token[1]}) def test_user_permissions(self): """ @@ -24,7 +28,7 @@ class PermissionsTestCase(TestCase): """ response = self.client.get('/api/2/users/') self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.json()), 2) + self.assertEqual(len(response.json()), 3) self.assertEqual(response.json()[0]['username'], 'legacy_user') self.assertEqual(response.json()[0]['email'], 'mail@localhost') self.assertEqual(response.json()[0]['first_name'], '') @@ -34,3 +38,54 @@ class PermissionsTestCase(TestCase): self.assertEqual(response.json()[1]['email'], 'test') self.assertEqual(response.json()[1]['first_name'], '') self.assertEqual(response.json()[1]['last_name'], '') + + def test_user_permission(self): + """ + Test that a user can only access their own data. + """ + #ä['add_logentry', 'change_logentry', 'delete_logentry', 'view_logentry', 'add_group', 'change_group', + #ä 'delete_group', 'view_group', 'add_permission', 'change_permission', 'delete_permission', 'view_permission', + #ä 'add_authtokeneventpermissions', 'change_authtokeneventpermissions', 'delete_authtokeneventpermissions', + #ä 'view_authtokeneventpermissions', 'add_eventpermission', 'change_eventpermission', 'delete_eventpermission', + #ä 'view_eventpermission', 'add_extendedauthtoken', 'change_extendedauthtoken', 'delete_extendedauthtoken', + #ä 'view_extendedauthtoken', 'add_extendeduser', 'change_extendeduser', 'delete_extendeduser', + #ä 'view_extendeduser', 'add_contenttype', 'change_contenttype', 'delete_contenttype', 'view_contenttype', + #ä 'add_file', 'change_file', 'delete_file', 'view_file', 'add_container', 'change_container', 'delete_container', + #ä 'view_container', 'add_event', 'change_event', 'delete_event', 'view_event', 'add_item', 'change_item', + #ä 'delete_item', 'match_item', 'view_item', 'add_authtoken', 'change_authtoken', 'delete_authtoken', + #ä 'view_authtoken', 'add_email', 'change_email', 'delete_email', 'view_email', 'add_eventaddress', + #ä 'change_eventaddress', 'delete_eventaddress', 'view_eventaddress', 'add_systemevent', 'change_systemevent', + #ä 'delete_systemevent', 'view_systemevent', 'add_session', 'change_session', 'delete_session', 'view_session', + #ä 'add_comment', 'change_comment', 'delete_comment', 'view_comment', 'add_issuethread', 'change_issuethread', + #ä 'delete_issuethread', 'send_mail', 'view_issuethread', 'add_statechange', 'change_statechange', + #ä 'delete_statechange', 'view_statechange'] + + user = ExtendedUser.objects.create_user('testuser2', 'test', 'test') + user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent1')) + user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent2')) + user.event_permissions.create(permission=Permission.objects.get(codename='add_item'), event=Event.objects.get(slug='testevent1')) + user.save() + print(user.get_all_permissions()) + #self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent1'))) + #self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent2'))) + #self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent1'))) + #self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent2'))) + + def test_item_api_permissions(self): + """ + Test that a user can only access their own data. + """ + response = self.client.get('/api/2/testevent1/items/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + + response = self.client.get('/api/2/testevent2/items/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + + response = self.newuser_client.get('/api/2/testevent1/items/') + self.assertEqual(response.status_code, 403) + + response = self.newuser_client.get('/api/2/testevent2/items/') + self.assertEqual(response.status_code, 403) + diff --git a/core/authentication/tests/v2/test_users.py b/core/authentication/tests/v2/test_users.py index d83d287..6838e64 100644 --- a/core/authentication/tests/v2/test_users.py +++ b/core/authentication/tests/v2/test_users.py @@ -1,4 +1,5 @@ from django.test import TestCase, Client +from django.contrib.auth.models import Permission from knox.models import AuthToken @@ -10,6 +11,7 @@ class UserApiTest(TestCase): def setUp(self): self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) self.user.save() self.token = AuthToken.objects.create(user=self.user) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) diff --git a/core/core/settings.py b/core/core/settings.py index c753624..f8d7ebb 100644 --- a/core/core/settings.py +++ b/core/core/settings.py @@ -65,7 +65,8 @@ INSTALLED_APPS = [ REST_FRAMEWORK = { 'TEST_REQUEST_DEFAULT_FORMAT': 'json', 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), - 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.DjangoModelPermissions'), } AUTH_USER_MODEL = 'authentication.ExtendedUser' diff --git a/core/files/tests/v2/test_files.py b/core/files/tests/v2/test_files.py index 1a8a02c..dedd647 100644 --- a/core/files/tests/v2/test_files.py +++ b/core/files/tests/v2/test_files.py @@ -1,4 +1,5 @@ from django.test import TestCase, Client +from django.contrib.auth.models import Permission from authentication.models import ExtendedUser from files.models import File @@ -11,6 +12,7 @@ class FileTestCase(TestCase): def setUp(self): super().setUp() self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) self.user.save() self.token = AuthToken.objects.create(user=self.user) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) @@ -38,8 +40,8 @@ class FileTestCase(TestCase): Item.objects.create(container=self.box, event=self.event, description='1') item = Item.objects.create(container=self.box, event=self.event, description='2') response = self.client.post('/api/2/files/', - {'data': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')}, - content_type='application/json') + {'data': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')}, + content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(len(response.json()['hash']), 64) diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py index a801fea..79e25f2 100644 --- a/core/inventory/api_v2.py +++ b/core/inventory/api_v2.py @@ -1,9 +1,11 @@ from datetime import datetime from django.urls import path, re_path +from django.contrib.auth.decorators import permission_required from rest_framework import routers, viewsets, serializers -from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated from files.models import File from inventory.models import Event, Container, Item @@ -37,7 +39,6 @@ class ContainerSerializer(serializers.ModelSerializer): class ContainerViewSet(viewsets.ModelViewSet): serializer_class = ContainerSerializer queryset = Container.objects.all() - permission_classes = [] class ItemSerializer(serializers.ModelSerializer): @@ -95,6 +96,8 @@ class ItemSerializer(serializers.ModelSerializer): @api_view(['GET']) +@permission_classes([IsAuthenticated]) +@permission_required('inventory.view_item', raise_exception=True) def search_items(request, event_slug, query): try: event = Event.objects.get(slug=event_slug) @@ -109,12 +112,17 @@ def search_items(request, event_slug, query): @api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) def item(request, event_slug): try: event = Event.objects.get(slug=event_slug) if request.method == 'GET': + if not request.user.has_event_perm(event, 'inventory.view_item'): + return Response(status=403) return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data) elif request.method == 'POST': + if not request.user.has_event_perm(event, 'inventory.add_item'): + return Response(status=403) validated_data = ItemSerializer(data=request.data) if validated_data.is_valid(): validated_data.save(event=event) @@ -124,18 +132,25 @@ def item(request, event_slug): @api_view(['GET', 'PUT', 'DELETE']) +@permission_classes([IsAuthenticated]) def item_by_id(request, event_slug, id): try: event = Event.objects.get(slug=event_slug) item = Item.objects.get(event=event, uid=id) if request.method == 'GET': + if not request.user.has_event_perm(event, 'inventory.view_item'): + return Response(status=403) return Response(ItemSerializer(item).data) elif request.method == 'PUT': + if not request.user.has_event_perm(event, 'inventory.change_item'): + return Response(status=403) validated_data = ItemSerializer(item, data=request.data) if validated_data.is_valid(): validated_data.save() return Response(validated_data.data) elif request.method == 'DELETE': + if not request.user.has_event_perm(event, 'inventory.delete_item'): + return Response(status=403) item.delete() return Response(status=204) except Item.DoesNotExist: diff --git a/core/inventory/tests/v2/test_api.py b/core/inventory/tests/v2/test_api.py index 9d01ca0..6904164 100644 --- a/core/inventory/tests/v2/test_api.py +++ b/core/inventory/tests/v2/test_api.py @@ -1,4 +1,5 @@ from django.test import TestCase, Client +from django.contrib.auth.models import Permission from knox.models import AuthToken from authentication.models import ExtendedUser @@ -9,6 +10,7 @@ class ApiTest(TestCase): def setUp(self): super().setUp() self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) self.user.save() self.token = AuthToken.objects.create(user=self.user) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) diff --git a/core/inventory/tests/v2/test_containers.py b/core/inventory/tests/v2/test_containers.py index a3507f0..58322cc 100644 --- a/core/inventory/tests/v2/test_containers.py +++ b/core/inventory/tests/v2/test_containers.py @@ -1,19 +1,27 @@ from django.test import TestCase, Client -from inventory.models import Container +from django.contrib.auth.models import Permission +from knox.models import AuthToken -client = Client() +from authentication.models import ExtendedUser +from inventory.models import Container class ContainerTestCase(TestCase): + def setUp(self): + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + def test_empty(self): - response = client.get('/api/2/boxes/') + response = self.client.get('/api/2/boxes/') self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), []) def test_members(self): Container.objects.create(name='BOX') - response = client.get('/api/2/boxes/') + response = self.client.get('/api/2/boxes/') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 1) self.assertEqual(response.json()[0]['cid'], 1) @@ -24,12 +32,12 @@ class ContainerTestCase(TestCase): Container.objects.create(name='BOX 1') Container.objects.create(name='BOX 2') Container.objects.create(name='BOX 3') - response = client.get('/api/2/boxes/') + response = self.client.get('/api/2/boxes/') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 3) def test_create_container(self): - response = client.post('/api/2/box/', {'name': 'BOX'}) + response = self.client.post('/api/2/box/', {'name': 'BOX'}) self.assertEqual(response.status_code, 201) self.assertEqual(response.json()['cid'], 1) self.assertEqual(response.json()['name'], 'BOX') @@ -39,9 +47,8 @@ class ContainerTestCase(TestCase): self.assertEqual(Container.objects.all()[0].name, 'BOX') def test_update_container(self): - from rest_framework.test import APIClient box = Container.objects.create(name='BOX 1') - response = APIClient().put(f'/api/2/box/{box.cid}/', {'name': 'BOX 2'}) + response = self.client.put(f'/api/2/box/{box.cid}/', {'name': 'BOX 2'}, content_type='application/json') self.assertEqual(response.status_code, 200) self.assertEqual(response.json()['cid'], 1) self.assertEqual(response.json()['name'], 'BOX 2') @@ -54,6 +61,6 @@ class ContainerTestCase(TestCase): box = Container.objects.create(name='BOX 1') Container.objects.create(name='BOX 2') self.assertEqual(len(Container.objects.all()), 2) - response = client.delete(f'/api/2/box/{box.cid}/') + response = self.client.delete(f'/api/2/box/{box.cid}/') self.assertEqual(response.status_code, 204) self.assertEqual(len(Container.objects.all()), 1) diff --git a/core/inventory/tests/v2/test_items.py b/core/inventory/tests/v2/test_items.py index 6578670..8e2adba 100644 --- a/core/inventory/tests/v2/test_items.py +++ b/core/inventory/tests/v2/test_items.py @@ -1,4 +1,5 @@ from django.test import TestCase, Client +from django.contrib.auth.models import Permission from knox.models import AuthToken from authentication.models import ExtendedUser @@ -13,6 +14,7 @@ class ItemTestCase(TestCase): self.event = Event.objects.create(slug='EVENT', name='Event') self.box = Container.objects.create(name='BOX') self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) self.token = AuthToken.objects.create(user=self.user) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) diff --git a/core/mail/tests/v2/test_mails.py b/core/mail/tests/v2/test_mails.py index 7e9fec5..8f1ba41 100644 --- a/core/mail/tests/v2/test_mails.py +++ b/core/mail/tests/v2/test_mails.py @@ -1,8 +1,9 @@ import inspect from unittest import mock -from knox.models import AuthToken from django.test import TestCase, Client +from django.contrib.auth.models import Permission +from knox.models import AuthToken from authentication.models import ExtendedUser from core.settings import MAIL_DOMAIN @@ -28,6 +29,7 @@ class EmailsApiTest(TestCase): def setUp(self): super().setUp() self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) self.user.save() self.token = AuthToken.objects.create(user=self.user) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) @@ -62,6 +64,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test def setUp(self): super().setUp() self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) self.user.save() self.token = AuthToken.objects.create(user=self.user) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py index 2aebbe4..c8eb103 100644 --- a/core/tickets/api_v2.py +++ b/core/tickets/api_v2.py @@ -1,8 +1,10 @@ import logging from django.urls import path -from rest_framework.decorators import api_view, permission_classes, authentication_classes +from django.contrib.auth.decorators import permission_required from rest_framework import routers, viewsets, serializers, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from asgiref.sync import async_to_sync @@ -56,6 +58,8 @@ class IssueViewSet(viewsets.ModelViewSet): @api_view(['POST']) +@permission_classes([IsAuthenticated]) +@permission_required('tickets.add_issuethread', raise_exception=True) def reply(request, pk): issue = IssueThread.objects.get(pk=pk) # email = issue.reply(request.data['body']) # TODO evaluate if this is a useful abstraction diff --git a/web/src/store/index.js b/web/src/store/index.js index 1f57aa1..2edd9ff 100644 --- a/web/src/store/index.js +++ b/web/src/store/index.js @@ -20,17 +20,26 @@ axios.interceptors.response.use(response => response, error => { return axios.request(error.config); } }); - } else - return Promise.reject(error); -}); - -axios.interceptors.response.use(response => response, error => { - console.log('error interceptor fired'); - console.error(error); // todo: toast error - console.log(Object.entries(error)); - - if (error.isAxiosError) { + } else if (error.response.status === 403) { const message = ` +
+ url: ${error.config.url}
+
+ method: ${error.config.method}
+
+ response-body: ${error.response && error.response.body}
+
url: ${error.config.url} @@ -40,11 +49,12 @@ axios.interceptors.response.use(response => response, error => { response-body: ${error.response && error.response.body}
`; - store.commit('createToast', {title: 'Error: HTTP', message, color: 'danger'}); - } else { - store.commit('createToast', {title: 'Error: Unknown', message: error.toString(), color: 'danger'}); + store.commit('createToast', {title: 'Error: HTTP', message, color: 'danger'}); + } else { + store.commit('createToast', {title: 'Error: Unknown', message: error.toString(), color: 'danger'}); + } + return Promise.reject(error); } - return Promise.reject(error); }); const store = new Vuex.Store({ @@ -226,8 +236,12 @@ const store = new Vuex.Store({ router.push({path: `/${getters.getEventSlug}/items/`, query: {box}}); }, async loadEventItems({commit, getters}) { - const {data} = await axios.get(`/2/${getters.getEventSlug}/items/`); - commit('replaceLoadedItems', data); + try { + const {data} = await axios.get(`/2/${getters.getEventSlug}/items/`); + commit('replaceLoadedItems', data); + } catch (e) { + console.error("Error loading items"); + } }, async searchEventItems({commit, getters}, query) { const foo = utf8.encode(query);