Verified Commit 0c8faf83 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Can now have multiple system actors

We also handle webfinger/activity serialization properly
parent 6c3b7ce1
import requests
from django.urls import reverse
from django.conf import settings
from dynamic_preferences.registries import global_preferences_registry
from . import models
def get_actor_data(actor_url):
response = requests.get(actor_url)
response.raise_for_status()
return response.json()
SYSTEM_ACTORS = {
'library': {
'get_actor': lambda: models.Actor(**get_base_system_actor_arguments('library')),
}
}
def get_base_system_actor_arguments(name):
preferences = global_preferences_registry.manager()
return {
'preferred_username': name,
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
'manually_approves_followers': True,
'url': reverse(
'federation:instance-actors-detail',
kwargs={'actor': name}),
'shared_inbox_url': reverse(
'federation:instance-actors-inbox',
kwargs={'actor': name}),
'inbox_url': reverse(
'federation:instance-actors-inbox',
kwargs={'actor': name}),
'outbox_url': reverse(
'federation:instance-actors-outbox',
kwargs={'actor': name}),
'public_key': preferences['federation__public_key'],
'summary': 'Bot account to federate with {}\'s library'.format(
settings.FEDERATION_HOSTNAME
),
}
import cryptography
from django.contrib.auth.models import AnonymousUser
from rest_framework import authentication
from rest_framework import exceptions
from . import actors
from . import keys
from . import serializers
from . import signing
class SignatureAuthentication(authentication.BaseAuthentication):
def authenticate(self, request):
try:
signature = request.META['headers']['Signature']
key_id = keys.get_key_id_from_signature_header(signature)
except KeyError:
raise exceptions.AuthenticationFailed('No signature')
except ValueError as e:
raise exceptions.AuthenticationFailed(str(e))
try:
actor_data = actors.get_actor_data(key_id)
except Exception as e:
raise exceptions.AuthenticationFailed(str(e))
try:
public_key = actor_data['publicKey']['publicKeyPem']
except KeyError:
raise exceptions.AuthenticationFailed('No public key found')
serializer = serializers.ActorSerializer(data=actor_data)
if not serializer.is_valid():
raise exceptions.AuthenticationFailed('Invalid actor payload')
try:
signing.verify_django(request, public_key.encode('utf-8'))
except cryptography.exceptions.InvalidSignature:
raise exceptions.AuthenticationFailed('Invalid signature')
user = AnonymousUser()
ac = serializer.build()
setattr(request, 'actor', ac)
return (user, None)
...@@ -2,10 +2,14 @@ from cryptography.hazmat.primitives import serialization as crypto_serialization ...@@ -2,10 +2,14 @@ from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend as crypto_default_backend from cryptography.hazmat.backends import default_backend as crypto_default_backend
import re
import requests import requests
import urllib.parse
from . import exceptions from . import exceptions
KEY_ID_REGEX = re.compile(r'keyId=\"(?P<id>.*)\"')
def get_key_pair(size=2048): def get_key_pair(size=2048):
key = rsa.generate_private_key( key = rsa.generate_private_key(
...@@ -25,19 +29,21 @@ def get_key_pair(size=2048): ...@@ -25,19 +29,21 @@ def get_key_pair(size=2048):
return private_key, public_key return private_key, public_key
def get_public_key(actor_url): def get_key_id_from_signature_header(header_string):
""" parts = header_string.split(',')
Given an actor_url, request it and extract publicKey data from
the response payload.
"""
response = requests.get(actor_url)
response.raise_for_status()
payload = response.json()
try: try:
return { raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
'public_key_pem': payload['publicKey']['publicKeyPem'], except IndexError:
'id': payload['publicKey']['id'], raise ValueError('Missing key id')
'owner': payload['publicKey']['owner'],
} match = KEY_ID_REGEX.match(raw_key_id)
except KeyError: if not match:
raise exceptions.MalformedPayload(str(payload)) raise ValueError('Invalid key id')
key_id = match.groups()[0]
url = urllib.parse.urlparse(key_id)
if not url.scheme or not url.netloc:
raise ValueError('Invalid url')
if url.scheme not in ['http', 'https']:
raise ValueError('Invalid shceme')
return key_id
import urllib.parse
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
from rest_framework import serializers
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from . import models
from . import utils from . import utils
def repr_instance_actor(): class ActorSerializer(serializers.ModelSerializer):
""" # left maps to activitypub fields, right to our internal models
We do not use a serializer here, since it's pretty static id = serializers.URLField(source='url')
""" outbox = serializers.URLField(source='outbox_url')
actor_url = utils.full_url(reverse('federation:instance-actor')) inbox = serializers.URLField(source='inbox_url')
preferences = global_preferences_registry.manager() following = serializers.URLField(source='following_url', required=False)
public_key = preferences['federation__public_key'] followers = serializers.URLField(source='followers_url', required=False)
preferredUsername = serializers.CharField(
source='preferred_username', required=False)
publicKey = serializers.JSONField(source='public_key', required=False)
manuallyApprovesFollowers = serializers.NullBooleanField(
source='manually_approves_followers', required=False)
class Meta:
model = models.Actor
fields = [
'id',
'type',
'name',
'summary',
'preferredUsername',
'publicKey',
'inbox',
'outbox',
'following',
'followers',
'manuallyApprovesFollowers',
]
return { def to_representation(self, instance):
'@context': [ ret = super().to_representation(instance)
ret['@context'] = [
'https://www.w3.org/ns/activitystreams', 'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1', 'https://w3id.org/security/v1',
{}, {},
], ]
'id': utils.full_url(reverse('federation:instance-actor')), if instance.public_key:
'type': 'Person', ret['publicKey'] = {
'inbox': utils.full_url(reverse('federation:instance-inbox')), 'owner': instance.url,
'outbox': utils.full_url(reverse('federation:instance-outbox')), 'publicKeyPem': instance.public_key,
'preferredUsername': 'service', 'id': '{}#main-key'.format(instance.url)
'name': 'Service Bot - {}'.format(settings.FEDERATION_HOSTNAME), }
'summary': 'Bot account for federating with {}'.format( ret['endpoints'] = {}
settings.FEDERATION_HOSTNAME if instance.shared_inbox_url:
), ret['endpoints']['sharedInbox'] = instance.shared_inbox_url
'publicKey': { return ret
'id': '{}#main-key'.format(actor_url),
'owner': actor_url, def prepare_missing_fields(self):
'publicKeyPem': public_key kwargs = {}
}, domain = urllib.parse.urlparse(self.validated_data['url']).netloc
kwargs['domain'] = domain
} for endpoint, url in self.initial_data.get('endpoints', {}).items():
if endpoint == 'sharedInbox':
kwargs['shared_inbox_url'] = url
break
try:
kwargs['public_key'] = self.initial_data['publicKey']['publicKeyPem']
except KeyError:
pass
return kwargs
def build(self):
d = self.validated_data.copy()
d.update(self.prepare_missing_fields())
return self.Meta.model(**d)
def save(self, **kwargs):
kwargs.update(self.prepare_missing_fields())
return super().save(**kwargs)
class ActorWebfingerSerializer(serializers.ModelSerializer):
class Meta:
model = models.Actor
fields = ['url']
def to_representation(self, instance):
data = {}
data['subject'] = 'acct:{}'.format(instance.webfinger_subject)
data['links'] = [
{
'rel': 'self',
'href': instance.url,
'type': 'application/activity+json'
}
]
data['aliases'] = [
instance.url
]
return data
...@@ -4,9 +4,9 @@ from . import views ...@@ -4,9 +4,9 @@ from . import views
router = routers.SimpleRouter(trailing_slash=False) router = routers.SimpleRouter(trailing_slash=False)
router.register( router.register(
r'federation/instance', r'federation/instance/actors',
views.InstanceViewSet, views.InstanceActorViewSet,
'instance') 'instance-actors')
router.register( router.register(
r'.well-known', r'.well-known',
views.WellKnownViewSet, views.WellKnownViewSet,
......
...@@ -5,8 +5,9 @@ from django.http import HttpResponse ...@@ -5,8 +5,9 @@ from django.http import HttpResponse
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework import views from rest_framework import views
from rest_framework import response from rest_framework import response
from rest_framework.decorators import list_route from rest_framework.decorators import list_route, detail_route
from . import actors
from . import renderers from . import renderers
from . import serializers from . import serializers
from . import webfinger from . import webfinger
...@@ -19,20 +20,30 @@ class FederationMixin(object): ...@@ -19,20 +20,30 @@ class FederationMixin(object):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
class InstanceViewSet(FederationMixin, viewsets.GenericViewSet): class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
lookup_field = 'actor'
lookup_value_regex = '[a-z]*'
authentication_classes = [] authentication_classes = []
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = [renderers.ActivityPubRenderer]
@list_route(methods=['get']) def get_object(self):
def actor(self, request, *args, **kwargs): try:
return response.Response(serializers.repr_instance_actor()) return actors.SYSTEM_ACTORS[self.kwargs['actor']]
except KeyError:
raise Http404
@list_route(methods=['get']) def retrieve(self, request, *args, **kwargs):
actor_conf = self.get_object()
actor = actor_conf['get_actor']()
serializer = serializers.ActorSerializer(actor)
return response.Response(serializer.data, status=200)
@detail_route(methods=['get'])
def inbox(self, request, *args, **kwargs): def inbox(self, request, *args, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@list_route(methods=['get']) @detail_route(methods=['get'])
def outbox(self, request, *args, **kwargs): def outbox(self, request, *args, **kwargs):
raise NotImplementedError() raise NotImplementedError()
...@@ -69,6 +80,5 @@ class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet): ...@@ -69,6 +80,5 @@ class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
def handler_acct(self, clean_result): def handler_acct(self, clean_result):
username, hostname = clean_result username, hostname = clean_result
if username == 'service': actor = actors.SYSTEM_ACTORS[username]['get_actor']()
return webfinger.serialize_system_acct() return serializers.ActorWebfingerSerializer(actor).data
return {}
...@@ -2,7 +2,9 @@ from django import forms ...@@ -2,7 +2,9 @@ from django import forms
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from . import actors
from . import utils from . import utils
VALID_RESOURCE_TYPES = ['acct'] VALID_RESOURCE_TYPES = ['acct']
...@@ -30,23 +32,7 @@ def clean_acct(acct_string): ...@@ -30,23 +32,7 @@ def clean_acct(acct_string):
if hostname != settings.FEDERATION_HOSTNAME: if hostname != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError('Invalid hostname') raise forms.ValidationError('Invalid hostname')
if username != 'service': if username not in actors.SYSTEM_ACTORS:
raise forms.ValidationError('Invalid username') raise forms.ValidationError('Invalid username')
return username, hostname return username, hostname
def serialize_system_acct():
return {
'subject': 'acct:service@{}'.format(settings.FEDERATION_HOSTNAME),
'aliases': [
utils.full_url(reverse('federation:instance-actor'))
],
'links': [
{
'rel': 'self',
'type': 'application/activity+json',
'href': utils.full_url(reverse('federation:instance-actor')),
}
]
}
from django.urls import reverse
from funkwhale_api.federation import actors
def test_actor_fetching(r_mock):
payload = {
'id': 'https://actor.mock/users/actor#main-key',
'owner': 'test',
'publicKeyPem': 'test_pem',
}
actor_url = 'https://actor.mock/'
r_mock.get(actor_url, json=payload)
r = actors.get_actor_data(actor_url)
assert r == payload
def test_get_library(settings, preferences):
preferences['federation__public_key'] = 'public_key'
expected = {
'preferred_username': 'library',
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': '{}\'s library'.format(settings.FEDERATION_HOSTNAME),
'manually_approves_followers': True,
'url': reverse(
'federation:instance-actors-detail',
kwargs={'actor': 'library'}),
'shared_inbox_url': reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'library'}),
'inbox_url': reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'library'}),
'public_key': 'public_key',
'summary': 'Bot account to federate with {}\'s library'.format(
settings.FEDERATION_HOSTNAME),
}
actor = actors.SYSTEM_ACTORS['library']['get_actor']()
for key, value in expected.items():
assert getattr(actor, key) == value
from funkwhale_api.federation import authentication
from funkwhale_api.federation import keys
from funkwhale_api.federation import signing
def test_authenticate(nodb_factories, mocker, api_request):
private, public = keys.get_key_pair()
actor_url = 'https://test.federation/actor'
mocker.patch(
'funkwhale_api.federation.actors.get_actor_data',
return_value={
'id': actor_url,
'outbox': 'https://test.com',
'inbox': 'https://test.com',
'publicKey': {
'publicKeyPem': public.decode('utf-8'),
'owner': actor_url,
'id': actor_url + '#main-key',
}
})
signed_request = nodb_factories['federation.SignedRequest'](
auth__key=private,
auth__key_id=actor_url + '#main-key'
)
prepared = signed_request.prepare()
django_request = api_request.get(
'/',
headers={
'Date': prepared.headers['date'],
'Signature': prepared.headers['signature'],
}
)
authenticator = authentication.SignatureAuthentication()
user, _ = authenticator.authenticate(django_request)
actor = django_request.actor
assert user.is_anonymous is True
assert actor.public_key == public.decode('utf-8')
assert actor.url == actor_url
import pytest
from funkwhale_api.federation import keys from funkwhale_api.federation import keys
def test_public_key_fetching(r_mock): @pytest.mark.parametrize('raw, expected', [
payload = { ('algorithm="test",keyId="https://test.com"', 'https://test.com'),
'id': 'https://actor.mock/users/actor#main-key', ('keyId="https://test.com",algorithm="test"', 'https://test.com'),
'owner': 'test', ])
'publicKeyPem': 'test_pem', def test_get_key_from_header(raw, expected):
} r = keys.get_key_id_from_signature_header(raw)
actor = 'https://actor.mock/' assert r == expected
r_mock.get(actor, json={'publicKey': payload})
r = keys.get_public_key(actor)
assert r['id'] == payload['id'] @pytest.mark.parametrize('raw', [
assert r['owner'] == payload['owner'] 'algorithm="test",keyid="badCase"',
assert r['public_key_pem'] == payload['publicKeyPem'] 'algorithm="test",wrong="wrong"',
'keyId = "wrong"',
'keyId=\'wrong\'',
'keyId="notanurl"',
'keyId="wrong://test.com"',
])
def test_get_key_from_header_invalid(raw):
with pytest.raises(ValueError):
keys.get_key_id_from_signature_header(raw)
from django.urls import reverse from django.urls import reverse
from funkwhale_api.federation import keys from funkwhale_api.federation import keys
from funkwhale_api.federation import models
from funkwhale_api.federation import serializers from funkwhale_api.federation import serializers
def test_repr_instance_actor(db, preferences, settings): def test_actor_serializer_from_ap(db):
_, public_key = keys.get_key_pair() payload = {
preferences['federation__public_key'] = public_key.decode('utf-8') 'id': 'https://test.federation/user',
settings.FEDERATION_HOSTNAME = 'test.federation' 'type': 'Person',
settings.FUNKWHALE_URL = 'https://test.federation' 'following': 'https://test.federation/user/following',
actor_url = settings.FUNKWHALE_URL + reverse('federation:instance-actor') 'followers': 'https://test.federation/user/followers',
inbox_url = settings.FUNKWHALE_URL + reverse('federation:instance-inbox') 'inbox': 'https://test.federation/user/inbox',
outbox_url = settings.FUNKWHALE_URL + reverse('federation:instance-outbox') 'outbox': 'https://test.federation/user/outbox',
'preferredUsername': 'user',
'name': 'Real User',
'summary': 'Hello world',
'url': 'https://test.federation/@user',
'manuallyApprovesFollowers': False,
'publicKey': {
'id': 'https://test.federation/user#main-key',
'owner': 'https://test.federation/user',
'publicKeyPem': 'yolo'
},
'endpoints': {
'sharedInbox': 'https://test.federation/inbox'
},
}
serializer = serializers.ActorSerializer(data=payload)
assert serializer.is_valid()
actor = serializer.build()
assert actor.url == payload['id']
assert actor.inbox_url == payload['inbox']
assert actor.outbox_url == payload['outbox']
assert actor.shared_inbox_url == payload['endpoints']['sharedInbox']