diff --git a/backend/.coveragerc b/backend/.coveragerc index 9cd1bb2..eae5531 100644 --- a/backend/.coveragerc +++ b/backend/.coveragerc @@ -7,8 +7,10 @@ show_missing = True skip_covered = True omit = */tests/* + */migrations/* backend/asgi.py backend/wsgi.py backend/settings.py manage.py - configure.py \ No newline at end of file + configure.py + testdata.py \ No newline at end of file diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 7ba8b20..2ee49ff 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -91,6 +91,10 @@ class ToolshedUser(AbstractUser): def __str__(self): return f"{self.username}@{self.domain}" + @property + def friends(self): + return self.public_identity.friends + def sign(self, message): if type(message) != str: raise TypeError('Message must be a string') diff --git a/backend/authentication/signature_auth.py b/backend/authentication/signature_auth.py index b46fc11..99b5060 100644 --- a/backend/authentication/signature_auth.py +++ b/backend/authentication/signature_auth.py @@ -1,5 +1,6 @@ from rest_framework import authentication -from authentication.models import ToolshedUser + +from authentication.models import KnownIdentity, ToolshedUser def split_userhandle_or_throw(userhandle): @@ -47,6 +48,21 @@ def verify_request(request, raw_request_body): return username, domain, signed_data, signature_bytes_hex +def authenticate_request_against_known_identities(request, raw_request_body): + try: + username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) + except ValueError: + return None + try: + author_identity = KnownIdentity.objects.get(username=username, domain=domain) + except KnownIdentity.DoesNotExist: + return None + if author_identity.verify(signed_data, signature_bytes_hex): + return author_identity + else: + return None + + def authenticate_request_against_local_users(request, raw_request_body): try: username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) @@ -62,6 +78,12 @@ def authenticate_request_against_local_users(request, raw_request_body): return None +class SignatureAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + return authenticate_request_against_known_identities( + request, request.body.decode('utf-8')), None + + class SignatureAuthenticationLocal(authentication.BaseAuthentication): def authenticate(self, request): return authenticate_request_against_local_users( diff --git a/backend/authentication/tests/helpers.py b/backend/authentication/tests/helpers.py index aa2a5d4..3eaaa55 100644 --- a/backend/authentication/tests/helpers.py +++ b/backend/authentication/tests/helpers.py @@ -13,10 +13,10 @@ class DummyExternalUser: self.username = username self.domain = domain self.__signing_key = SigningKey.generate() - self.public_identity, _ = KnownIdentity.objects.get_or_create( + self.public_identity = KnownIdentity.objects.get_or_create( username=username, domain=domain, - public_key=self.public_key()) if known else None + public_key=self.public_key())[0] if known else None def __str__(self): return self.username + '@' + self.domain diff --git a/backend/authentication/tests/test_auth.py b/backend/authentication/tests/test_auth.py index e1da5b5..2089e52 100644 --- a/backend/authentication/tests/test_auth.py +++ b/backend/authentication/tests/test_auth.py @@ -5,7 +5,7 @@ from nacl.encoding import HexEncoder from nacl.signing import SigningKey from authentication.models import ToolshedUser, KnownIdentity -from authentication.tests import UserTestCase, SignatureAuthClient +from authentication.tests import UserTestCase, SignatureAuthClient, DummyExternalUser from hostadmin.models import Domain @@ -307,6 +307,50 @@ class UserApiTestCase(UserTestCase): self.assertEqual(reply.status_code, 403) +class FriendApiTestCase(UserTestCase): + def setUp(self): + super().setUp() + self.local_user1.friends.add(self.local_user2.public_identity) + self.local_user1.friends.add(self.ext_user1.public_identity) + self.ext_user1.friends.add(self.local_user1.public_identity) + self.anonymous_client = Client(SERVER_NAME='testserver') + self.client = SignatureAuthClient() + + def test_friend_local(self): + reply = self.client.get('/api/friends/', self.local_user1) + self.assertEqual(reply.status_code, 200) + + def test_friend_external(self): + reply = self.client.get('/api/friends/', self.ext_user1) + self.assertEqual(reply.status_code, 200) + + def test_friend_fail(self): + reply = self.anonymous_client.get('/api/friends/') + self.assertEqual(reply.status_code, 403) + + def test_friend_fail2(self): + target = "/api/friends/" + signature = self.local_user1.sign("http://testserver2" + target) + header = {'HTTP_AUTHORIZATION': 'Signature ' + str(self.local_user1) + ':' + signature} + reply = self.anonymous_client.get(target, **header) + self.assertEqual(reply.status_code, 403) + + def test_friend_fail3(self): + target = "/api/friends/" + unknown_user = DummyExternalUser('extuser3', 'external.org', False) + signature = unknown_user.sign("http://testserver" + target) + header = {'HTTP_AUTHORIZATION': 'Signature ' + str(unknown_user) + ':' + signature} + reply = self.anonymous_client.get(target, **header) + self.assertEqual(reply.status_code, 403) + + def test_friend_fail4(self): + target = "/api/friends/" + signature = self.local_user1.sign("http://testserver" + target) + header = {'HTTP_AUTHORIZATION': 'Auth ' + str(self.local_user1) + ':' + signature} + reply = self.anonymous_client.get(target, **header) + self.assertEqual(reply.status_code, 403) + + class LoginApiTestCase(UserTestCase): user = None client = Client(SERVER_NAME='testserver') diff --git a/backend/backend/settings.py b/backend/backend/settings.py index a746d7c..8fa1adb 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -48,6 +48,7 @@ INSTALLED_APPS = [ 'drf_yasg', 'authentication', 'hostadmin', + 'toolshed', ] REST_FRAMEWORK = { @@ -148,11 +149,6 @@ USE_TZ = True STATIC_ROOT = 'staticfiles' STATIC_URL = '/static/' -# Extra places for collectstatic to find static files. -STATICFILES_DIRS = ( - BASE_DIR / 'static', -) - # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 9de988a..a7602fc 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -32,5 +32,6 @@ urlpatterns = [ path('djangoadmin/', admin.site.urls), path('auth/', include('authentication.api')), path('admin/', include('hostadmin.api')), + path('api/', include('toolshed.api.friend')), path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'), ] diff --git a/backend/configure.py b/backend/configure.py index c962017..9f10659 100755 --- a/backend/configure.py +++ b/backend/configure.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import os import sys +from argparse import ArgumentParser import dotenv @@ -75,15 +76,67 @@ def configure(): from django.core.management import call_command call_command('createsuperuser') - if not os.path.exists('static'): - if yesno("No static directory found, do you want to create one?", default=True): - os.mkdir('static') - call_command('collectstatic', '--no-input') - # if yesno("Do you want to load initial data?"): - # call_command('loaddata', 'initial_data.json') + +def reset(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") + import django + + django.setup() + + try: + os.remove('db.sqlite3') + except FileNotFoundError: + pass + + os.system("git clean -f */migrations") + + from django.core.management import call_command + + apps = ['authentication', 'authtoken', 'sessions', 'hostadmin', 'toolshed', 'admin'] + for app in apps: + call_command('makemigrations', app) + + for app in apps: + call_command('migrate', app) + + +def testdata(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings") + import django + + django.setup() + if os.path.exists('testdata.py'): + from testdata import create_test_data + create_test_data() + else: + print('No testdata file found') + print('Please create a file named testdata.py in the shared_data directory. the function create_test_data() ' + 'will be called automatically and should create all necessary test data.') + + +def main(): + parser = ArgumentParser(description='Toolshed Server Configuration') + parser.add_argument('--yes', '-y', help='Answer yes to all questions', action='store_true') + parser.add_argument('--no', '-n', help='Answer no to all questions', action='store_true') + parser.add_argument('cmd', help='Command', default='configure', nargs='?') + args = parser.parse_args() + + if args.yes and args.no: + print('Error: --yes and --no are mutually exclusive') + exit(1) + + if args.cmd == 'configure': + configure() + elif args.cmd == 'reset': + reset() + elif args.cmd == 'testdata': + testdata() + else: + print('Unknown command: {}'.format(args.cmd)) + exit(1) if __name__ == '__main__': - configure() + main() diff --git a/backend/toolshed/__init__.py b/backend/toolshed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/toolshed/api/__init__.py b/backend/toolshed/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/toolshed/api/friend.py b/backend/toolshed/api/friend.py new file mode 100644 index 0000000..1fe36a1 --- /dev/null +++ b/backend/toolshed/api/friend.py @@ -0,0 +1,24 @@ +from django.urls import path +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.viewsets import ViewSetMixin + +from authentication.signature_auth import SignatureAuthentication +from toolshed.serializers import FriendSerializer + + +class Friends(APIView, ViewSetMixin): + authentication_classes = [SignatureAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, format=None): # /api/friends/ # + user = request.user + friends = user.friends.all() + serializer = FriendSerializer(friends, many=True) + return Response(serializer.data) + + +urlpatterns = [ + path('friends/', Friends.as_view(), name='friends'), +] diff --git a/backend/toolshed/migrations/__init__.py b/backend/toolshed/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py new file mode 100644 index 0000000..19b8935 --- /dev/null +++ b/backend/toolshed/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers +from authentication.models import KnownIdentity + + +class FriendSerializer(serializers.ModelSerializer): + username = serializers.SerializerMethodField() + + class Meta: + model = KnownIdentity + fields = ['username', 'public_key'] + + def get_username(self, obj): + return obj.username + '@' + obj.domain diff --git a/backend/toolshed/tests/__init__.py b/backend/toolshed/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/toolshed/tests/test_friend.py b/backend/toolshed/tests/test_friend.py new file mode 100644 index 0000000..aa0f647 --- /dev/null +++ b/backend/toolshed/tests/test_friend.py @@ -0,0 +1,75 @@ +from authentication.tests import UserTestCase, SignatureAuthClient + +client = SignatureAuthClient() + + +class FriendTestCase(UserTestCase): + def setUp(self): + super().setUp() + + def test_friendship_iternal(self): + self.assertEqual(self.local_user1.friends.count(), 0) + self.assertEqual(self.local_user2.friends.count(), 0) + self.assertEqual(self.ext_user1.friends.count(), 0) + self.assertEqual(self.ext_user2.friends.count(), 0) + self.local_user1.friends.add(self.local_user2.public_identity) + self.assertEqual(self.local_user1.friends.count(), 1) + self.assertEqual(self.local_user2.friends.count(), 1) + self.assertEqual(self.ext_user1.friends.count(), 0) + self.assertEqual(self.ext_user2.friends.count(), 0) + self.assertEqual(self.local_user1.friends.first(), self.local_user2.public_identity) + self.assertEqual(self.local_user2.friends.first(), self.local_user1.public_identity) + + def test_friendship_external(self): + self.assertEqual(self.local_user1.friends.count(), 0) + self.assertEqual(self.local_user2.friends.count(), 0) + self.assertEqual(self.ext_user1.friends.count(), 0) + self.assertEqual(self.ext_user2.friends.count(), 0) + self.local_user1.friends.add(self.ext_user1.public_identity) + self.assertEqual(self.local_user1.friends.count(), 1) + self.assertEqual(self.local_user2.friends.count(), 0) + self.assertEqual(self.ext_user1.friends.count(), 1) + self.assertEqual(self.ext_user2.friends.count(), 0) + self.assertEqual(self.local_user1.friends.first(), self.ext_user1.public_identity) + self.assertEqual(self.ext_user1.friends.first(), self.local_user1.public_identity) + + def test_friend_from_external(self): + self.assertEqual(self.local_user1.friends.count(), 0) + self.assertEqual(self.local_user2.friends.count(), 0) + self.assertEqual(self.ext_user1.friends.count(), 0) + self.assertEqual(self.ext_user2.friends.count(), 0) + self.ext_user1.friends.add(self.local_user1.public_identity) + self.assertEqual(self.local_user1.friends.count(), 1) + self.assertEqual(self.local_user2.friends.count(), 0) + self.assertEqual(self.ext_user1.friends.count(), 1) + self.assertEqual(self.ext_user2.friends.count(), 0) + self.assertEqual(self.local_user1.friends.first(), self.ext_user1.public_identity) + self.assertEqual(self.ext_user1.friends.first(), self.local_user1.public_identity) + + +class FriendApiTestCase(UserTestCase): + + def setUp(self): + super().setUp() + self.local_user1.friends.add(self.local_user2.public_identity) + self.local_user1.friends.add(self.ext_user1.public_identity) + self.ext_user1.friends.add(self.local_user1.public_identity) + + def test_friend_list_internal1(self): + reply = client.get('/api/friends/', self.local_user1) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 2) + self.assertEqual(reply.json()[0]['username'], str(self.local_user2)) + self.assertEqual(reply.json()[1]['username'], str(self.ext_user1)) + + def test_friend_list_internal2(self): + reply = client.get('/api/friends/', self.local_user2) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 1) + self.assertEqual(reply.json()[0]['username'], str(self.local_user1)) + + def test_friend_list_external(self): + reply = client.get('/api/friends/', self.ext_user1) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 1) + self.assertEqual(reply.json()[0]['username'], str(self.local_user1))