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 skip_covered = True
omit = omit =
*/tests/* */tests/*
*/migrations/*
backend/asgi.py backend/asgi.py
backend/wsgi.py backend/wsgi.py
backend/settings.py backend/settings.py
manage.py manage.py
configure.py configure.py
testdata.py

View file

@ -91,6 +91,10 @@ class ToolshedUser(AbstractUser):
def __str__(self): def __str__(self):
return f"{self.username}@{self.domain}" return f"{self.username}@{self.domain}"
@property
def friends(self):
return self.public_identity.friends
def sign(self, message): def sign(self, message):
if type(message) != str: if type(message) != str:
raise TypeError('Message must be a string') raise TypeError('Message must be a string')

View file

@ -1,5 +1,6 @@
from rest_framework import authentication from rest_framework import authentication
from authentication.models import ToolshedUser
from authentication.models import KnownIdentity, ToolshedUser
def split_userhandle_or_throw(userhandle): 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 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): def authenticate_request_against_local_users(request, raw_request_body):
try: try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body) 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 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): class SignatureAuthenticationLocal(authentication.BaseAuthentication):
def authenticate(self, request): def authenticate(self, request):
return authenticate_request_against_local_users( return authenticate_request_against_local_users(

View file

@ -13,10 +13,10 @@ class DummyExternalUser:
self.username = username self.username = username
self.domain = domain self.domain = domain
self.__signing_key = SigningKey.generate() self.__signing_key = SigningKey.generate()
self.public_identity, _ = KnownIdentity.objects.get_or_create( self.public_identity = KnownIdentity.objects.get_or_create(
username=username, username=username,
domain=domain, domain=domain,
public_key=self.public_key()) if known else None public_key=self.public_key())[0] if known else None
def __str__(self): def __str__(self):
return self.username + '@' + self.domain return self.username + '@' + self.domain

View file

@ -5,7 +5,7 @@ from nacl.encoding import HexEncoder
from nacl.signing import SigningKey from nacl.signing import SigningKey
from authentication.models import ToolshedUser, KnownIdentity from authentication.models import ToolshedUser, KnownIdentity
from authentication.tests import UserTestCase, SignatureAuthClient from authentication.tests import UserTestCase, SignatureAuthClient, DummyExternalUser
from hostadmin.models import Domain from hostadmin.models import Domain
@ -307,6 +307,50 @@ class UserApiTestCase(UserTestCase):
self.assertEqual(reply.status_code, 403) 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): class LoginApiTestCase(UserTestCase):
user = None user = None
client = Client(SERVER_NAME='testserver') client = Client(SERVER_NAME='testserver')

View file

@ -48,6 +48,7 @@ INSTALLED_APPS = [
'drf_yasg', 'drf_yasg',
'authentication', 'authentication',
'hostadmin', 'hostadmin',
'toolshed',
] ]
REST_FRAMEWORK = { REST_FRAMEWORK = {
@ -148,11 +149,6 @@ USE_TZ = True
STATIC_ROOT = 'staticfiles' STATIC_ROOT = 'staticfiles'
STATIC_URL = '/static/' STATIC_URL = '/static/'
# Extra places for collectstatic to find static files.
STATICFILES_DIRS = (
BASE_DIR / 'static',
)
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # 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('djangoadmin/', admin.site.urls),
path('auth/', include('authentication.api')), path('auth/', include('authentication.api')),
path('admin/', include('hostadmin.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'), path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
] ]

View file

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import sys import sys
from argparse import ArgumentParser
import dotenv import dotenv
@ -75,15 +76,67 @@ def configure():
from django.core.management import call_command from django.core.management import call_command
call_command('createsuperuser') 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') 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__': 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))