diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 6c4e50f..b89b561 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -102,6 +102,9 @@ class ToolshedUser(AbstractUser): private_key = SigningKey(self.private_key.encode(), encoder=HexEncoder) return private_key.sign(message.encode('utf-8'), encoder=HexEncoder).signature.decode('utf-8') + def public_key(self): + return self.public_identity.public_key + class FriendRequestOutgoing(models.Model): secret = models.CharField(max_length=255) diff --git a/backend/authentication/signature_auth.py b/backend/authentication/signature_auth.py index 99b5060..fd36705 100644 --- a/backend/authentication/signature_auth.py +++ b/backend/authentication/signature_auth.py @@ -1,3 +1,5 @@ +from nacl.exceptions import BadSignatureError +from nacl.signing import VerifyKey from rest_framework import authentication from authentication.models import KnownIdentity, ToolshedUser @@ -48,6 +50,28 @@ def verify_request(request, raw_request_body): return username, domain, signed_data, signature_bytes_hex +def verify_incoming_friend_request(request, raw_request_body): + try: + username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) + except ValueError: + return False + try: + befriender = request.data['befriender'] + befriender_key = request.data['befriender_key'] + except KeyError: + return False + if username + "@" + domain != befriender: + return False + if len(befriender_key) != 64: + return False + verify_key = VerifyKey(bytes.fromhex(befriender_key)) + try: + verify_key.verify(signed_data.encode('utf-8'), bytes.fromhex(signature_bytes_hex)) + return True + except BadSignatureError: + return False + + def authenticate_request_against_known_identities(request, raw_request_body): try: username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) diff --git a/backend/toolshed/api/friend.py b/backend/toolshed/api/friend.py index 1fe36a1..66f8342 100644 --- a/backend/toolshed/api/friend.py +++ b/backend/toolshed/api/friend.py @@ -1,11 +1,18 @@ +import secrets + from django.urls import path +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.generics import get_object_or_404 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 +from authentication.models import KnownIdentity, FriendRequestIncoming, FriendRequestOutgoing, ToolshedUser +from authentication.signature_auth import verify_incoming_friend_request, split_userhandle_or_throw, \ + authenticate_request_against_local_users, SignatureAuthentication +from toolshed.serializers import FriendSerializer, FriendRequestSerializer class Friends(APIView, ViewSetMixin): @@ -18,7 +25,102 @@ class Friends(APIView, ViewSetMixin): serializer = FriendSerializer(friends, many=True) return Response(serializer.data) + def post(self, request, format=None): # /api/friends/ + # only for local users + try: + user = request.user + incoming_request = FriendRequestIncoming.objects.get( + pk=request.data.get('friend_request_id'), + secret=request.data.get('secret')) + befriender, _ = KnownIdentity.objects.get_or_create( + username=incoming_request.befriender_username, + domain=incoming_request.befriender_domain, + public_key=incoming_request.befriender_public_key + ) + befriender.save() + user.user.get().friends.add(befriender) + user.user.get().save() + incoming_request.delete() + return Response(status=status.HTTP_201_CREATED, data={'status': 'accepted'}) + except FriendRequestIncoming.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'not found'}) + + +class FriendsRequests(APIView, ViewSetMixin): + def get(self, request, format=None): # /api/friendrequests/ + raw_request = request.body.decode('utf-8') + if user := authenticate_request_against_local_users(request, raw_request): + friends_requests = user.friend_requests_incoming.all() + serializer = FriendRequestSerializer(friends_requests, many=True) + return Response(serializer.data) + else: + return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'}) + + def post(self, request, format=None): # /api/friendrequests/ + raw_request = request.body.decode('utf-8') + if 'befriender' not in request.data or 'befriendee' not in request.data: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'missing parameters'}) + befriender_username, befriender_domain = split_userhandle_or_throw(request.data['befriender']) + befriendee_username, befriendee_domain = split_userhandle_or_throw(request.data['befriendee']) + if befriender_domain == befriendee_domain and befriender_username == befriendee_username: + return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'cannot befriend yourself'}) + if user := authenticate_request_against_local_users(request, raw_request): + secret = secrets.token_hex(64) + befriendee_user = ToolshedUser.objects.filter(username=befriendee_username, domain=befriendee_domain) + if befriendee_user.exists(): + FriendRequestIncoming.objects.create( + befriender_username=befriender_username, + befriender_domain=befriender_domain, + befriender_public_key=user.public_identity.public_key, + secret=secret, # request.data['secret'] # TODO ?? + befriendee_user=befriendee_user.get(), + ) + return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': "pending"}) + else: + FriendRequestOutgoing.objects.create( + befriender_user=user, + befriendee_username=befriendee_username, + befriendee_domain=befriendee_domain, + secret=secret, # request.data['secret'] # TODO ?? + ) + return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': "pending"}) + elif verify_incoming_friend_request(request, raw_request): + try: + befriendee = ToolshedUser.objects.get(username=befriendee_username, domain=befriendee_domain) + outgoing = FriendRequestOutgoing.objects.filter( + secret=request.data['secret'], + befriender_user=befriendee, # both sides match + befriendee_username=befriender_username, + befriendee_domain=befriender_domain) + if outgoing.exists(): + befriender, _ = KnownIdentity.objects.get_or_create( + username=befriender_username, + domain=befriender_domain, + public_key=request.data['befriender_key'] + ) + befriender.save() + befriendee.friends.add(befriender) + befriendee.save() + outgoing.delete() + return Response(status=status.HTTP_201_CREATED, data={'status': "accepted"}) + else: + FriendRequestIncoming.objects.create( + befriender_username=befriender_username, + befriender_domain=befriender_domain, + befriender_public_key=request.data['befriender_key'], + befriendee_user=befriendee, + secret=request.data['secret'] + ) + return Response(status=status.HTTP_201_CREATED, data={'status': "pending"}) + except ToolshedUser.DoesNotExist: + return Response(status=status.HTTP_400_BAD_REQUEST) + except KeyError: + return Response(status=status.HTTP_400_BAD_REQUEST) + else: + return Response(status=status.HTTP_400_BAD_REQUEST) + urlpatterns = [ path('friends/', Friends.as_view(), name='friends'), + path('friendrequests/', FriendsRequests.as_view(), name='friendrequests'), ] diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index cccebd4..32cde24 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,6 +1,5 @@ from rest_framework import serializers - -from authentication.models import KnownIdentity, ToolshedUser +from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming from authentication.serializers import OwnerSerializer from files.models import File from files.serializers import FileSerializer @@ -18,6 +17,17 @@ class FriendSerializer(serializers.ModelSerializer): return obj.username + '@' + obj.domain +class FriendRequestSerializer(serializers.ModelSerializer): + befriender = serializers.SerializerMethodField() + + class Meta: + model = FriendRequestIncoming + fields = ['befriender', 'befriender_public_key', 'secret', 'id'] + + def get_befriender(self, obj): + return obj.befriender_username + '@' + obj.befriender_domain + + class PropertySerializer(serializers.ModelSerializer): category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name') diff --git a/backend/toolshed/tests/test_friend.py b/backend/toolshed/tests/test_friend.py index fec6971..f96b2a1 100644 --- a/backend/toolshed/tests/test_friend.py +++ b/backend/toolshed/tests/test_friend.py @@ -1,4 +1,6 @@ +from django.test import Client from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase +from authentication.models import FriendRequestIncoming, FriendRequestOutgoing client = SignatureAuthClient() @@ -75,3 +77,265 @@ class FriendApiTestCase(UserTestMixin, ToolshedTestCase): self.assertEqual(reply.status_code, 200) self.assertEqual(len(reply.json()), 1) self.assertEqual(reply.json()[0]['username'], str(self.f['local_user1'])) + + +# what ~should~ happen: +# 1. user x@A sends a friend request to user y@B +# 1.1. x@A's client sends a POST request to A/api/friendrequests/ with body {from: x@A, to: y@B} +# 1.2. A's backend creates a FriendRequestOutgoing object, containing x@A's identity and y@B's name +# 1.3. x@A's client sends a POST request to B/api/friendrequests/ with body +# {from: x@A, to: y@B, public_key: x@A's public key} +# 1.4. B's backend creates a FriendRequestIncoming object, containing y@B's and x@A's identities +# 2. user y@B accepts the friend request +# 2.1. y@B's client sends a POST request to A/api/friendsrequests/ with body +# {from: x@A, to: y@B, public_key: y@B's public key} +# 2.2. A's backend matches the data to the FriendRequestOutgoing object, deletes both and creates a Friend object, +# containing x@A's and y@B's identities +# 2.3. y@B's client sends a POST request to B/api/friends/ containing the id of the FriendRequestIncoming object +# 2.4. B's backend creates a Friend object, using the identities from the FriendRequestIncoming object + + +class FriendRequestListTestCase(UserTestMixin, ToolshedTestCase): + + def setUp(self): + super().setUp() + self.prepare_users() + FriendRequestIncoming.objects.create( + befriender_username=self.f['ext_user2'].username, befriender_domain=self.f['ext_user2'].domain, + befriender_public_key=self.f['ext_user2'].public_key(), befriendee_user=self.f['local_user1'], + secret='secret1').save() + + def test_friend_request_withouth_auth(self): + reply = Client().get('/api/friendrequests/') + self.assertEqual(reply.status_code, 401) + + def test_friend_request_empty(self): + reply = client.get('/api/friendrequests/', self.f['local_user2']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.json(), []) + + def test_friend_request_list(self): + reply = client.get('/api/friendrequests/', self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(len(reply.json()), 1) + self.assertEqual(reply.json()[0]['befriender'], str(self.f['ext_user2'])) + self.assertEqual(reply.json()[0]['befriender_public_key'], self.f['ext_user2'].public_key()) + + +class FriendRequestIncomingTestCase(UserTestMixin, ToolshedTestCase): + def setUp(self): + super().setUp() + self.prepare_users() + FriendRequestIncoming.objects.create( + befriender_username=self.f['ext_user2'].username, befriender_domain=self.f['ext_user2'].domain, + befriender_public_key=self.f['ext_user2'].public_key(), befriendee_user=self.f['local_user1'], + secret='secret1').save() + + def test_post_request(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriender_key': befriender.public_key(), + 'befriendee': str(befriendee), + 'secret': 'secret2' + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(reply.json()['status'], 'pending') + self.assertEqual(FriendRequestIncoming.objects.count(), 2) + incoming = FriendRequestIncoming.objects.get(befriender_username=befriender.username, + befriender_domain=befriender.domain) + self.assertEqual(incoming.befriendee_user, befriendee) + self.assertEqual(incoming.befriender_public_key, befriender.public_key()) + self.assertEqual(incoming.secret, 'secret2') + + def test_post_request_local(self): + befriender = self.f['local_user2'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriendee': str(befriendee), + # 'secret': 'secret2' + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(reply.json()['status'], 'pending') + self.assertEqual(FriendRequestIncoming.objects.count(), 2) + incoming = FriendRequestIncoming.objects.get(befriender_username=befriender.username, + befriender_domain=befriender.domain) + self.assertEqual(incoming.befriendee_user, befriendee) + self.assertEqual(incoming.befriender_public_key, befriender.public_key()) + # self.assertEqual(incoming.secret, 'secret2') + + def test_post_request_withouth_auth(self): + reply = Client().post('/api/friendrequests/') + self.assertEqual(reply.status_code, 400) + + def test_post_request_broken_header(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user1'] + broken_client = SignatureAuthClient(header_prefix='broken ') + reply = broken_client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriender_key': befriender.public_key(), + 'befriendee': str(befriendee), + 'secret': 'secret2' + }) + self.assertEqual(reply.status_code, 400) + + def test_post_request_missing_key(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriendee': str(befriendee), + 'secret': 'secret2' + }) + self.assertEqual(reply.status_code, 400) + + def test_post_request_breaking_key(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriendee': str(befriendee), + 'secret': 'secret2', + 'befriender_key': 'broken' + }) + self.assertEqual(reply.status_code, 400) + + def test_post_request_wrong_befriender(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(self.f['local_user2']), + 'befriender_key': befriender.public_key(), + 'befriendee': str(befriendee), + 'secret': 'secret2' + }) + self.assertEqual(reply.status_code, 400) + + def test_post_request_bad_signature(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user1'] + bad_signature = SignatureAuthClient(bad_signature=True) + reply = bad_signature.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriender_key': befriender.public_key(), + 'befriendee': str(befriendee), + 'secret': 'secret2' + }) + self.assertEqual(reply.status_code, 400) + + def test_post_request_self(self): + befriender = self.f['local_user1'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriender_key': befriender.public_key(), + 'befriendee': str(befriendee), + 'secret': 'secret2' + }) + self.assertEqual(reply.status_code, 400) + + def test_post_request_befreindee_not_found(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriender_key': befriender.public_key(), + 'befriendee': 'nonexistent@' + befriendee.domain, + 'secret': 'secret2' + }) + self.assertEqual(reply.status_code, 400) + + def test_post_request_missing_secret(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriender_key': befriender.public_key(), + 'befriendee': str(befriendee) + }) + self.assertEqual(reply.status_code, 400) + + def test_accept_request(self): + befriender = self.f['ext_user2'] + befriendee = self.f['local_user1'] + request = FriendRequestIncoming.objects.filter(befriender_username=befriender.username, + befriender_domain=befriender.domain, + befriendee_user=befriendee).first() + reply = client.post('/api/friends/', befriendee, { + 'friend_request_id': request.id, + 'secret': request.secret + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(reply.json(), {'status': 'accepted'}) + + def test_accept_request(self): + befriender = self.f['ext_user2'] + befriendee = self.f['local_user1'] + request = FriendRequestIncoming.objects.filter(befriender_username=befriender.username, + befriender_domain=befriender.domain, + befriendee_user=befriendee).first() + reply = client.post('/api/friends/', befriendee, { + 'friend_request_id': request.id, + 'secret': request.secret + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(reply.json(), {'status': 'accepted'}) + + def test_accept_request_not_found(self): + befriender = self.f['ext_user2'] + befriendee = self.f['local_user1'] + reply = client.post('/api/friends/', befriendee, { + 'friend_request_id': 999, + 'secret': 'secret1' + }) + self.assertEqual(reply.status_code, 404) + + +class FriendRequestOutgoingTestCase(UserTestMixin, ToolshedTestCase): + + def setUp(self): + super().setUp() + self.prepare_users() + FriendRequestOutgoing.objects.create( + befriender_user=self.f['local_user2'], + befriendee_username=self.f['ext_user1'].username, + befriendee_domain=self.f['ext_user1'].domain, + secret='secret3' + ).save() + + def test_post_outgoing_friend_request(self): + befriender = self.f['local_user1'] + befriendee = self.f['ext_user1'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriendee': str(befriendee), + }) + self.assertEqual(reply.status_code, 201) + self.assertTrue('status' in reply.json()) + self.assertEqual(reply.json()['status'], 'pending') + self.assertEqual(FriendRequestOutgoing.objects.count(), 2) + outgoing = FriendRequestOutgoing.objects.get(befriender_user=befriender) + self.assertTrue('secret' in reply.json()) + self.assertEqual(reply.json()['secret'], outgoing.secret) + self.assertEqual(outgoing.befriendee_username, befriendee.username) + self.assertEqual(outgoing.befriendee_domain, befriendee.domain) + + def test_accept_request(self): + befriender = self.f['ext_user1'] + befriendee = self.f['local_user2'] + reply = client.post('/api/friendrequests/', befriender, { + 'befriender': str(befriender), + 'befriender_key': befriender.public_key(), + 'befriendee': str(befriendee), + 'secret': 'secret3' + }) + self.assertEqual(reply.status_code, 201) + self.assertEqual(reply.json(), {'status': 'accepted'}) + self.assertEqual(FriendRequestIncoming.objects.count(), 0) + self.assertEqual(FriendRequestOutgoing.objects.count(), 0) + self.assertEqual(befriendee.friends.count(), 1) + self.assertEqual(befriendee.friends.first().username, befriender.username) + self.assertEqual(befriendee.friends.first().domain, befriender.domain)