Can now have multiple system actors
We also handle webfinger/activity serialization properly
This commit is contained in:
parent
6c3b7ce154
commit
0c8faf83c5
13 changed files with 495 additions and 154 deletions
48
api/funkwhale_api/federation/actors.py
Normal file
48
api/funkwhale_api/federation/actors.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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
|
||||
),
|
||||
}
|
||||
46
api/funkwhale_api/federation/authentication.py
Normal file
46
api/funkwhale_api/federation/authentication.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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
|
|||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
||||
|
||||
import re
|
||||
import requests
|
||||
import urllib.parse
|
||||
|
||||
from . import exceptions
|
||||
|
||||
KEY_ID_REGEX = re.compile(r'keyId=\"(?P<id>.*)\"')
|
||||
|
||||
|
||||
def get_key_pair(size=2048):
|
||||
key = rsa.generate_private_key(
|
||||
|
|
@ -25,19 +29,21 @@ def get_key_pair(size=2048):
|
|||
return private_key, public_key
|
||||
|
||||
|
||||
def get_public_key(actor_url):
|
||||
"""
|
||||
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()
|
||||
def get_key_id_from_signature_header(header_string):
|
||||
parts = header_string.split(',')
|
||||
try:
|
||||
return {
|
||||
'public_key_pem': payload['publicKey']['publicKeyPem'],
|
||||
'id': payload['publicKey']['id'],
|
||||
'owner': payload['publicKey']['owner'],
|
||||
}
|
||||
except KeyError:
|
||||
raise exceptions.MalformedPayload(str(payload))
|
||||
raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
|
||||
except IndexError:
|
||||
raise ValueError('Missing key id')
|
||||
|
||||
match = KEY_ID_REGEX.match(raw_key_id)
|
||||
if not match:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,38 +1,101 @@
|
|||
import urllib.parse
|
||||
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from . import models
|
||||
from . import utils
|
||||
|
||||
|
||||
def repr_instance_actor():
|
||||
"""
|
||||
We do not use a serializer here, since it's pretty static
|
||||
"""
|
||||
actor_url = utils.full_url(reverse('federation:instance-actor'))
|
||||
preferences = global_preferences_registry.manager()
|
||||
public_key = preferences['federation__public_key']
|
||||
class ActorSerializer(serializers.ModelSerializer):
|
||||
# left maps to activitypub fields, right to our internal models
|
||||
id = serializers.URLField(source='url')
|
||||
outbox = serializers.URLField(source='outbox_url')
|
||||
inbox = serializers.URLField(source='inbox_url')
|
||||
following = serializers.URLField(source='following_url', required=False)
|
||||
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)
|
||||
|
||||
return {
|
||||
'@context': [
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
fields = [
|
||||
'id',
|
||||
'type',
|
||||
'name',
|
||||
'summary',
|
||||
'preferredUsername',
|
||||
'publicKey',
|
||||
'inbox',
|
||||
'outbox',
|
||||
'following',
|
||||
'followers',
|
||||
'manuallyApprovesFollowers',
|
||||
]
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret['@context'] = [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': utils.full_url(reverse('federation:instance-actor')),
|
||||
'type': 'Person',
|
||||
'inbox': utils.full_url(reverse('federation:instance-inbox')),
|
||||
'outbox': utils.full_url(reverse('federation:instance-outbox')),
|
||||
'preferredUsername': 'service',
|
||||
'name': 'Service Bot - {}'.format(settings.FEDERATION_HOSTNAME),
|
||||
'summary': 'Bot account for federating with {}'.format(
|
||||
settings.FEDERATION_HOSTNAME
|
||||
),
|
||||
'publicKey': {
|
||||
'id': '{}#main-key'.format(actor_url),
|
||||
'owner': actor_url,
|
||||
'publicKeyPem': public_key
|
||||
},
|
||||
]
|
||||
if instance.public_key:
|
||||
ret['publicKey'] = {
|
||||
'owner': instance.url,
|
||||
'publicKeyPem': instance.public_key,
|
||||
'id': '{}#main-key'.format(instance.url)
|
||||
}
|
||||
ret['endpoints'] = {}
|
||||
if instance.shared_inbox_url:
|
||||
ret['endpoints']['sharedInbox'] = instance.shared_inbox_url
|
||||
return ret
|
||||
|
||||
}
|
||||
def prepare_missing_fields(self):
|
||||
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
|
|||
|
||||
router = routers.SimpleRouter(trailing_slash=False)
|
||||
router.register(
|
||||
r'federation/instance',
|
||||
views.InstanceViewSet,
|
||||
'instance')
|
||||
r'federation/instance/actors',
|
||||
views.InstanceActorViewSet,
|
||||
'instance-actors')
|
||||
router.register(
|
||||
r'.well-known',
|
||||
views.WellKnownViewSet,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ from django.http import HttpResponse
|
|||
from rest_framework import viewsets
|
||||
from rest_framework import views
|
||||
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 serializers
|
||||
from . import webfinger
|
||||
|
|
@ -19,20 +20,30 @@ class FederationMixin(object):
|
|||
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 = []
|
||||
permission_classes = []
|
||||
renderer_classes = [renderers.ActivityPubRenderer]
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def actor(self, request, *args, **kwargs):
|
||||
return response.Response(serializers.repr_instance_actor())
|
||||
def get_object(self):
|
||||
try:
|
||||
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):
|
||||
raise NotImplementedError()
|
||||
|
||||
@list_route(methods=['get'])
|
||||
@detail_route(methods=['get'])
|
||||
def outbox(self, request, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
|
@ -69,6 +80,5 @@ class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
|
|||
|
||||
def handler_acct(self, clean_result):
|
||||
username, hostname = clean_result
|
||||
if username == 'service':
|
||||
return webfinger.serialize_system_acct()
|
||||
return {}
|
||||
actor = actors.SYSTEM_ACTORS[username]['get_actor']()
|
||||
return serializers.ActorWebfingerSerializer(actor).data
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ from django import forms
|
|||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from . import actors
|
||||
from . import utils
|
||||
|
||||
VALID_RESOURCE_TYPES = ['acct']
|
||||
|
||||
|
||||
|
|
@ -30,23 +32,7 @@ def clean_acct(acct_string):
|
|||
if hostname != settings.FEDERATION_HOSTNAME:
|
||||
raise forms.ValidationError('Invalid hostname')
|
||||
|
||||
if username != 'service':
|
||||
if username not in actors.SYSTEM_ACTORS:
|
||||
raise forms.ValidationError('Invalid username')
|
||||
|
||||
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')),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue