add /friends endpoint
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
j3d1 2023-06-22 02:14:52 +02:00
parent 0b92db278b
commit 91cd5c57b3
15 changed files with 251 additions and 17 deletions

View file

@ -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
testdata.py

View file

@ -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')

View file

@ -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(

View file

@ -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

View file

@ -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')

View file

@ -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

View file

@ -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'),
]

View file

@ -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()

View file

View file

View file

@ -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'),
]

View file

View file

@ -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

View file

View file

@ -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))