Merge branch 'federation-inbox' into 'develop'

Federation inbox

See merge request funkwhale/funkwhale!121
This commit is contained in:
Eliot Berriot 2018-04-02 17:28:53 +00:00
commit 7bb15a3aa1
39 changed files with 1396 additions and 253 deletions

View File

@ -1,3 +1,9 @@
API_AUTHENTICATION_REQUIRED=True
RAVEN_ENABLED=false
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
DJANGO_ALLOWED_HOSTS=localhost,nginx
DJANGO_SETTINGS_MODULE=config.settings.local
DJANGO_SECRET_KEY=dev
C_FORCE_ROOT=true
FUNKWHALE_URL=http://localhost
PYTHONDONTWRITEBYTECODE=true

View File

@ -13,6 +13,7 @@ stages:
test_api:
services:
- postgres:9.4
- redis:3
stage: test
image: funkwhale/funkwhale:latest
cache:
@ -24,6 +25,7 @@ test_api:
DATABASE_URL: "postgresql://postgres@postgres/postgres"
FUNKWHALE_URL: "https://funkwhale.ci"
CACHEOPS_ENABLED: "false"
DJANGO_SETTINGS_MODULE: config.settings.local
before_script:
- cd api

View File

@ -1,7 +1,3 @@
#!/bin/bash
set -e
if [ $1 = "pytest" ]; then
# let pytest.ini handle it
unset DJANGO_SETTINGS_MODULE
fi
exec "$@"

View File

@ -344,7 +344,12 @@ REST_FRAMEWORK = {
),
'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination',
'PAGE_SIZE': 25,
'DEFAULT_PARSER_CLASSES': (
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.FormParser',
'rest_framework.parsers.MultiPartParser',
'funkwhale_api.federation.parsers.ActivityParser',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
@ -396,6 +401,9 @@ PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
ACCOUNT_USERNAME_BLACKLIST = [
'funkwhale',
'library',
'test',
'status',
'root',
'admin',
'owner',

View File

@ -72,6 +72,10 @@ LOGGING = {
'handlers':['console'],
'propagate': True,
'level':'DEBUG',
}
},
'': {
'level': 'DEBUG',
'handlers': ['console'],
},
},
}

View File

@ -1,29 +0,0 @@
from .common import * # noqa
SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
# Mail settings
# ------------------------------------------------------------------------------
EMAIL_HOST = 'localhost'
EMAIL_PORT = 1025
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
default='django.core.mail.backends.console.EmailBackend')
# CACHING
# ------------------------------------------------------------------------------
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': ''
}
}
CELERY_BROKER_URL = 'memory://'
########## CELERY
# In development, all tasks will be executed locally by blocking until the task returns
CELERY_TASK_ALWAYS_EAGER = True
########## END CELERY
# Your local stuff: Below this line define 3rd party library settings
API_AUTHENTICATION_REQUIRED = False
CACHEOPS_ENABLED = False

View File

@ -0,0 +1,85 @@
import logging
import json
import requests
import requests_http_signature
from . import signing
logger = logging.getLogger(__name__)
ACTIVITY_TYPES = [
'Accept',
'Add',
'Announce',
'Arrive',
'Block',
'Create',
'Delete',
'Dislike',
'Flag',
'Follow',
'Ignore',
'Invite',
'Join',
'Leave',
'Like',
'Listen',
'Move',
'Offer',
'Question',
'Reject',
'Read',
'Remove',
'TentativeReject',
'TentativeAccept',
'Travel',
'Undo',
'Update',
'View',
]
OBJECT_TYPES = [
'Article',
'Audio',
'Document',
'Event',
'Image',
'Note',
'Page',
'Place',
'Profile',
'Relationship',
'Tombstone',
'Video',
]
def deliver(activity, on_behalf_of, to=[]):
from . import actors
logger.info('Preparing activity delivery to %s', to)
auth = requests_http_signature.HTTPSignatureAuth(
use_auth_header=False,
headers=[
'(request-target)',
'user-agent',
'host',
'date',
'content-type',],
algorithm='rsa-sha256',
key=on_behalf_of.private_key.encode('utf-8'),
key_id=on_behalf_of.private_key_id,
)
for url in to:
recipient_actor = actors.get_actor(url)
logger.debug('delivering to %s', recipient_actor.inbox_url)
logger.debug('activity content: %s', json.dumps(activity))
response = requests.post(
auth=auth,
json=activity,
url=recipient_actor.inbox_url,
headers={
'Content-Type': 'application/activity+json'
}
)
response.raise_for_status()
logger.debug('Remote answered with %s', response.status_code)

View File

@ -0,0 +1,222 @@
import logging
import requests
import xml
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.exceptions import PermissionDenied
from dynamic_preferences.registries import global_preferences_registry
from . import activity
from . import factories
from . import models
from . import serializers
from . import utils
logger = logging.getLogger(__name__)
def remove_tags(text):
logger.debug('Removing tags from %s', text)
return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext())
def get_actor_data(actor_url):
response = requests.get(
actor_url,
headers={
'Accept': 'application/activity+json',
}
)
response.raise_for_status()
try:
return response.json()
except:
raise ValueError(
'Invalid actor payload: {}'.format(response.text))
def get_actor(actor_url):
data = get_actor_data(actor_url)
serializer = serializers.ActorSerializer(data=data)
serializer.is_valid(raise_exception=True)
return serializer.build()
class SystemActor(object):
additional_attributes = {}
def get_actor_instance(self):
a = models.Actor(
**self.get_instance_argument(
self.id,
name=self.name,
summary=self.summary,
**self.additional_attributes
)
)
a.pk = self.id
return a
def get_instance_argument(self, id, name, summary, **kwargs):
preferences = global_preferences_registry.manager()
p = {
'preferred_username': id,
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': name.format(host=settings.FEDERATION_HOSTNAME),
'manually_approves_followers': True,
'url': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': id})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': id})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': id})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': id})),
'public_key': preferences['federation__public_key'],
'private_key': preferences['federation__private_key'],
'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
}
p.update(kwargs)
return p
def get_inbox(self, data, actor=None):
raise NotImplementedError
def post_inbox(self, data, actor=None):
raise NotImplementedError
def get_outbox(self, data, actor=None):
raise NotImplementedError
def post_outbox(self, data, actor=None):
raise NotImplementedError
class LibraryActor(SystemActor):
id = 'library'
name = '{host}\'s library'
summary = 'Bot account to federate with {host}\'s library'
additional_attributes = {
'manually_approves_followers': True
}
class TestActor(SystemActor):
id = 'test'
name = '{host}\'s test account'
summary = (
'Bot account to test federation with {host}. '
'Send me /ping and I\'ll answer you.'
)
additional_attributes = {
'manually_approves_followers': False
}
def get_outbox(self, data, actor=None):
return {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"id": utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': self.id})),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []
}
def post_inbox(self, data, actor=None):
if actor is None:
raise PermissionDenied('Actor not authenticated')
serializer = serializers.ActivitySerializer(
data=data, context={'actor': actor})
serializer.is_valid(raise_exception=True)
ac = serializer.validated_data
logger.info('Received activity on %s inbox', self.id)
if ac['type'] == 'Create' and ac['object']['type'] == 'Note':
# we received a toot \o/
command = self.parse_command(ac['object']['content'])
logger.debug('Parsed command: %s', command)
if command == 'ping':
self.handle_ping(ac, actor)
def parse_command(self, message):
"""
Remove any links or fancy markup to extract /command from
a note message.
"""
raw = remove_tags(message)
try:
return raw.split('/')[1]
except IndexError:
return
def handle_ping(self, ac, sender):
now = timezone.now()
test_actor = self.get_actor_instance()
reply_url = 'https://{}/activities/note/{}'.format(
settings.FEDERATION_HOSTNAME, now.timestamp()
)
reply_content = '{} Pong!'.format(
sender.mention_username
)
reply_activity = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
'type': 'Create',
'actor': test_actor.url,
'id': '{}/activity'.format(reply_url),
'published': now.isoformat(),
'to': ac['actor'],
'cc': [],
'object': factories.NoteFactory(
content='Pong!',
summary=None,
published=now.isoformat(),
id=reply_url,
inReplyTo=ac['object']['id'],
sensitive=False,
url=reply_url,
to=[ac['actor']],
attributedTo=test_actor.url,
cc=[],
attachment=[],
tag=[
{
"type": "Mention",
"href": ac['actor'],
"name": sender.mention_username
}
]
)
}
activity.deliver(
reply_activity,
to=[ac['actor']],
on_behalf_of=test_actor)
SYSTEM_ACTORS = {
'library': LibraryActor(),
'test': TestActor(),
}

View File

@ -0,0 +1,52 @@
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
from . import utils
class SignatureAuthentication(authentication.BaseAuthentication):
def authenticate_actor(self, request):
headers = utils.clean_wsgi_headers(request.META)
try:
signature = headers['Signature']
key_id = keys.get_key_id_from_signature_header(signature)
except KeyError:
return
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: {}'.format(serializer.errors))
try:
signing.verify_django(request, public_key.encode('utf-8'))
except cryptography.exceptions.InvalidSignature:
raise exceptions.AuthenticationFailed('Invalid signature')
return serializer.build()
def authenticate(self, request):
setattr(request, 'actor', None)
actor = self.authenticate_actor(request)
user = AnonymousUser()
setattr(request, 'actor', actor)
return (user, None)

View File

@ -2,3 +2,7 @@
class MalformedPayload(ValueError):
pass
class MissingSignature(KeyError):
pass

View File

@ -2,9 +2,12 @@ import factory
import requests
import requests_http_signature
from django.utils import timezone
from funkwhale_api.factories import registry
from . import keys
from . import models
registry.register(keys.get_key_pair, name='federation.KeyPair')
@ -15,7 +18,13 @@ class SignatureAuthFactory(factory.Factory):
algorithm = 'rsa-sha256'
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
key_id = factory.Faker('url')
use_auth_header = False
headers = [
'(request-target)',
'user-agent',
'host',
'date',
'content-type',]
class Meta:
model = requests_http_signature.HTTPSignatureAuth
@ -28,3 +37,55 @@ class SignedRequestFactory(factory.Factory):
class Meta:
model = requests.Request
@factory.post_generation
def headers(self, create, extracted, **kwargs):
default_headers = {
'User-Agent': 'Test',
'Host': 'test.host',
'Date': 'Right now',
'Content-Type': 'application/activity+json'
}
if extracted:
default_headers.update(extracted)
self.headers.update(default_headers)
@registry.register
class ActorFactory(factory.DjangoModelFactory):
public_key = None
private_key = None
preferred_username = factory.Faker('user_name')
summary = factory.Faker('paragraph')
domain = factory.Faker('domain_name')
url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username))
inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username))
outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username))
class Meta:
model = models.Actor
@classmethod
def _generate(cls, create, attrs):
has_public = attrs.get('public_key') is not None
has_private = attrs.get('private_key') is not None
if not has_public and not has_private:
private, public = keys.get_key_pair()
attrs['private_key'] = private.decode('utf-8')
attrs['public_key'] = public.decode('utf-8')
return super()._generate(create, attrs)
@registry.register(name='federation.Note')
class NoteFactory(factory.Factory):
type = 'Note'
id = factory.Faker('url')
published = factory.LazyFunction(
lambda: timezone.now().isoformat()
)
inReplyTo = None
content = factory.Faker('sentence')
class Meta:
model = dict

View File

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

View File

@ -0,0 +1,37 @@
# Generated by Django 2.0.3 on 2018-03-31 13:43
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Actor',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField(db_index=True, max_length=500, unique=True)),
('outbox_url', models.URLField(max_length=500)),
('inbox_url', models.URLField(max_length=500)),
('following_url', models.URLField(blank=True, max_length=500, null=True)),
('followers_url', models.URLField(blank=True, max_length=500, null=True)),
('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)),
('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)),
('name', models.CharField(blank=True, max_length=200, null=True)),
('domain', models.CharField(max_length=1000)),
('summary', models.CharField(blank=True, max_length=500, null=True)),
('preferred_username', models.CharField(blank=True, max_length=200, null=True)),
('public_key', models.CharField(blank=True, max_length=5000, null=True)),
('private_key', models.CharField(blank=True, max_length=5000, null=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)),
('manually_approves_followers', models.NullBooleanField(default=None)),
],
),
]

View File

@ -0,0 +1,59 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
TYPE_CHOICES = [
('Person', 'Person'),
('Application', 'Application'),
('Group', 'Group'),
('Organization', 'Organization'),
('Service', 'Service'),
]
class Actor(models.Model):
url = models.URLField(unique=True, max_length=500, db_index=True)
outbox_url = models.URLField(max_length=500)
inbox_url = models.URLField(max_length=500)
following_url = models.URLField(max_length=500, null=True, blank=True)
followers_url = models.URLField(max_length=500, null=True, blank=True)
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
type = models.CharField(
choices=TYPE_CHOICES, default='Person', max_length=25)
name = models.CharField(max_length=200, null=True, blank=True)
domain = models.CharField(max_length=1000)
summary = models.CharField(max_length=500, null=True, blank=True)
preferred_username = models.CharField(
max_length=200, null=True, blank=True)
public_key = models.CharField(max_length=5000, null=True, blank=True)
private_key = models.CharField(max_length=5000, null=True, blank=True)
creation_date = models.DateTimeField(default=timezone.now)
last_fetch_date = models.DateTimeField(
default=timezone.now)
manually_approves_followers = models.NullBooleanField(default=None)
@property
def webfinger_subject(self):
return '{}@{}'.format(
self.preferred_username,
settings.FEDERATION_HOSTNAME,
)
@property
def private_key_id(self):
return '{}#main-key'.format(self.url)
@property
def mention_username(self):
return '@{}@{}'.format(self.preferred_username, self.domain)
def save(self, **kwargs):
lowercase_fields = [
'domain',
]
for field in lowercase_fields:
v = getattr(self, field, None)
if v:
setattr(self, field, v.lower())
super().save(**kwargs)

View File

@ -0,0 +1,5 @@
from rest_framework import parsers
class ActivityParser(parsers.JSONParser):
media_type = 'application/activity+json'

View File

@ -1,38 +1,175 @@
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 activity
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)
summary = serializers.CharField(max_length=None, 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)
def validate_summary(self, value):
if value:
return value[:500]
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
class ActivitySerializer(serializers.Serializer):
actor = serializers.URLField()
id = serializers.URLField()
type = serializers.ChoiceField(
choices=[(c, c) for c in activity.ACTIVITY_TYPES])
object = serializers.JSONField()
def validate_object(self, value):
try:
type = value['type']
except KeyError:
raise serializers.ValidationError('Missing object type')
try:
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError(
'Unsupported type {}'.format(type))
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
def validate_actor(self, value):
request_actor = self.context.get('actor')
if request_actor and request_actor.url != value:
raise serializers.ValidationError(
'The actor making the request do not match'
' the activity actor'
)
return value
class ObjectSerializer(serializers.Serializer):
id = serializers.URLField()
url = serializers.URLField(required=False, allow_null=True)
type = serializers.ChoiceField(
choices=[(c, c) for c in activity.OBJECT_TYPES])
content = serializers.CharField(
required=False, allow_null=True)
summary = serializers.CharField(
required=False, allow_null=True)
name = serializers.CharField(
required=False, allow_null=True)
published = serializers.DateTimeField(
required=False, allow_null=True)
updated = serializers.DateTimeField(
required=False, allow_null=True)
to = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
cc = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
bto = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
bcc = serializers.ListField(
child=serializers.URLField(),
required=False, allow_null=True)
OBJECT_SERIALIZERS = {
t: ObjectSerializer
for t in activity.OBJECT_TYPES
}

View File

@ -1,11 +1,18 @@
import logging
import requests
import requests_http_signature
from . import exceptions
from . import utils
logger = logging.getLogger(__name__)
def verify(request, public_key):
return requests_http_signature.HTTPSignatureAuth.verify(
request,
key_resolver=lambda **kwargs: public_key
key_resolver=lambda **kwargs: public_key,
use_auth_header=False,
)
@ -14,21 +21,35 @@ def verify_django(django_request, public_key):
Given a django WSGI request, create an underlying requests.PreparedRequest
instance we can verify
"""
headers = django_request.META.get('headers', {}).copy()
headers = utils.clean_wsgi_headers(django_request.META)
for h, v in list(headers.items()):
# we include lower-cased version of the headers for compatibility
# with requests_http_signature
headers[h.lower()] = v
try:
signature = headers['authorization']
signature = headers['Signature']
except KeyError:
raise exceptions.MissingSignature
url = 'http://noop{}'.format(django_request.path)
query = django_request.META['QUERY_STRING']
if query:
url += '?{}'.format(query)
signature_headers = signature.split('headers="')[1].split('",')[0]
expected = signature_headers.split(' ')
logger.debug('Signature expected headers: %s', expected)
for header in expected:
try:
headers[header]
except KeyError:
logger.debug('Missing header: %s', header)
request = requests.Request(
method=django_request.method,
url='http://noop',
url=url,
data=django_request.body,
headers=headers)
for h in request.headers.keys():
v = request.headers[h]
if v:
request.headers[h] = str(v)
prepared_request = request.prepare()
return verify(request, public_key)

View File

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

View File

@ -12,3 +12,24 @@ def full_url(path):
return root + '/' + path
else:
return root + path
def clean_wsgi_headers(raw_headers):
"""
Convert WSGI headers from CONTENT_TYPE to Content-Type notation
"""
cleaned = {}
non_prefixed = [
'content_type',
'content_length',
]
for raw_header, value in raw_headers.items():
h = raw_header.lower()
if not h.startswith('http_') and h not in non_prefixed:
continue
words = h.replace('http_', '', 1).split('_')
cleaned_header = '-'.join([w.capitalize() for w in words])
cleaned[cleaned_header] = value
return cleaned

View File

@ -5,8 +5,10 @@ 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 authentication
from . import renderers
from . import serializers
from . import webfinger
@ -19,22 +21,50 @@ class FederationMixin(object):
return super().dispatch(request, *args, **kwargs)
class InstanceViewSet(FederationMixin, viewsets.GenericViewSet):
authentication_classes = []
class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
lookup_field = 'actor'
lookup_value_regex = '[a-z]*'
authentication_classes = [
authentication.SignatureAuthentication]
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):
system_actor = self.get_object()
actor = system_actor.get_actor_instance()
serializer = serializers.ActorSerializer(actor)
return response.Response(serializer.data, status=200)
@detail_route(methods=['get', 'post'])
def inbox(self, request, *args, **kwargs):
raise NotImplementedError()
system_actor = self.get_object()
handler = getattr(system_actor, '{}_inbox'.format(
request.method.lower()
))
@list_route(methods=['get'])
try:
data = handler(request.data, actor=request.actor)
except NotImplementedError:
return response.Response(status=405)
return response.Response(data, status=200)
@detail_route(methods=['get', 'post'])
def outbox(self, request, *args, **kwargs):
raise NotImplementedError()
system_actor = self.get_object()
handler = getattr(system_actor, '{}_outbox'.format(
request.method.lower()
))
try:
data = handler(request.data, actor=request.actor)
except NotImplementedError:
return response.Response(status=405)
return response.Response(data, status=200)
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
@ -69,6 +99,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_instance()
return serializers.ActorWebfingerSerializer(actor).data

View File

@ -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']
@ -27,26 +29,11 @@ def clean_acct(acct_string):
except ValueError:
raise forms.ValidationError('Invalid format')
if hostname != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError('Invalid hostname')
if hostname.lower() != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError(
'Invalid hostname {}'.format(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')),
}
]
}

View File

@ -61,4 +61,6 @@ django-cacheops>=4,<4.1
daphne==2.0.4
cryptography>=2,<3
requests-http-signature==0.0.3
# requests-http-signature==0.0.3
# clone until the branch is merged and released upstream
git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support

View File

@ -10,4 +10,5 @@ pytest-mock
pytest-sugar
pytest-xdist
pytest-cov
pytest-env
requests-mock

View File

@ -7,6 +7,12 @@ max-line-length = 120
exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
[tool:pytest]
DJANGO_SETTINGS_MODULE=config.settings.test
python_files = tests.py test_*.py *_tests.py
testpaths = tests
env =
SECRET_KEY=test
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
CELERY_BROKER_URL=memory://
CELERY_TASK_ALWAYS_EAGER=True
CACHEOPS_ENABLED=False
FEDERATION_HOSTNAME=test.federation

View File

@ -1,11 +1,13 @@
import factory
import tempfile
import shutil
import pytest
import requests_mock
import shutil
import tempfile
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
from django.test import client
from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient
@ -118,6 +120,11 @@ def api_request():
return APIRequestFactory()
@pytest.fixture
def fake_request():
return client.RequestFactory()
@pytest.fixture
def activity_registry():
r = record.registry

View File

@ -0,0 +1,10 @@
import pytest
@pytest.fixture
def authenticated_actor(nodb_factories, mocker):
actor = nodb_factories['federation.Actor']()
mocker.patch(
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',
return_value=actor)
yield actor

View File

@ -0,0 +1,32 @@
from funkwhale_api.federation import activity
def test_deliver(nodb_factories, r_mock, mocker):
to = nodb_factories['federation.Actor']()
mocker.patch(
'funkwhale_api.federation.actors.get_actor',
return_value=to)
sender = nodb_factories['federation.Actor']()
ac = {
'id': 'http://test.federation/activity',
'type': 'Create',
'actor': sender.url,
'object': {
'id': 'http://test.federation/note',
'type': 'Note',
'content': 'Hello',
}
}
r_mock.post(to.inbox_url)
activity.deliver(
ac,
to=[to.url],
on_behalf_of=sender,
)
request = r_mock.request_history[0]
assert r_mock.called is True
assert r_mock.call_count == 1
assert request.url == to.inbox_url
assert request.headers['content-type'] == 'application/activity+json'

View File

@ -0,0 +1,190 @@
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework import exceptions
from funkwhale_api.federation import actors
from funkwhale_api.federation import serializers
from funkwhale_api.federation import utils
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': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': 'library'})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'library'})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'library'})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
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_instance()
for key, value in expected.items():
assert getattr(actor, key) == value
def test_get_test(settings, preferences):
preferences['federation__public_key'] = 'public_key'
expected = {
'preferred_username': 'test',
'domain': settings.FEDERATION_HOSTNAME,
'type': 'Person',
'name': '{}\'s test account'.format(settings.FEDERATION_HOSTNAME),
'manually_approves_followers': False,
'url': utils.full_url(
reverse(
'federation:instance-actors-detail',
kwargs={'actor': 'test'})),
'shared_inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'test'})),
'inbox_url': utils.full_url(
reverse(
'federation:instance-actors-inbox',
kwargs={'actor': 'test'})),
'outbox_url': utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': 'test'})),
'public_key': 'public_key',
'summary': 'Bot account to test federation with {}. Send me /ping and I\'ll answer you.'.format(
settings.FEDERATION_HOSTNAME),
}
actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
for key, value in expected.items():
assert getattr(actor, key) == value
def test_test_get_outbox():
expected = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"id": utils.full_url(
reverse(
'federation:instance-actors-outbox',
kwargs={'actor': 'test'})),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []
}
data = actors.SYSTEM_ACTORS['test'].get_outbox({}, actor=None)
assert data == expected
def test_test_post_inbox_requires_authenticated_actor():
with pytest.raises(exceptions.PermissionDenied):
actors.SYSTEM_ACTORS['test'].post_inbox({}, actor=None)
def test_test_post_outbox_validates_actor(nodb_factories):
actor = nodb_factories['federation.Actor']()
data = {
'actor': 'noop'
}
with pytest.raises(exceptions.ValidationError) as exc_info:
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
msg = 'The actor making the request do not match'
assert msg in exc_info.value
def test_test_post_outbox_handles_create_note(
settings, mocker, factories):
deliver = mocker.patch(
'funkwhale_api.federation.activity.deliver')
actor = factories['federation.Actor']()
now = timezone.now()
mocker.patch('django.utils.timezone.now', return_value=now)
data = {
'actor': actor.url,
'type': 'Create',
'id': 'http://test.federation/activity',
'object': {
'type': 'Note',
'id': 'http://test.federation/object',
'content': '<p><a>@mention</a> /ping</p>'
}
}
test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
expected_note = factories['federation.Note'](
id='https://test.federation/activities/note/{}'.format(
now.timestamp()
),
content='Pong!',
published=now.isoformat(),
inReplyTo=data['object']['id'],
cc=[],
summary=None,
sensitive=False,
attributedTo=test_actor.url,
attachment=[],
to=[actor.url],
url='https://{}/activities/note/{}'.format(
settings.FEDERATION_HOSTNAME, now.timestamp()
),
tag=[{
'href': actor.url,
'name': actor.mention_username,
'type': 'Mention',
}]
)
expected_activity = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{}
],
'actor': test_actor.url,
'id': 'https://{}/activities/note/{}/activity'.format(
settings.FEDERATION_HOSTNAME, now.timestamp()
),
'to': actor.url,
'type': 'Create',
'published': now.isoformat(),
'object': expected_note,
'cc': [],
}
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
deliver.assert_called_once_with(
expected_activity,
to=[actor.url],
on_behalf_of=actors.SYSTEM_ACTORS['test'].get_actor_instance()
)

View File

@ -0,0 +1,42 @@
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',
auth__headers=[
'date',
]
)
prepared = signed_request.prepare()
django_request = api_request.get(
'/',
**{
'HTTP_DATE': prepared.headers['date'],
'HTTP_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

View File

@ -1,16 +1,25 @@
import pytest
from funkwhale_api.federation import keys
def test_public_key_fetching(r_mock):
payload = {
'id': 'https://actor.mock/users/actor#main-key',
'owner': 'test',
'publicKeyPem': 'test_pem',
}
actor = 'https://actor.mock/'
r_mock.get(actor, json={'publicKey': payload})
r = keys.get_public_key(actor)
@pytest.mark.parametrize('raw, expected', [
('algorithm="test",keyId="https://test.com"', 'https://test.com'),
('keyId="https://test.com",algorithm="test"', 'https://test.com'),
])
def test_get_key_from_header(raw, expected):
r = keys.get_key_id_from_signature_header(raw)
assert r == expected
assert r['id'] == payload['id']
assert r['owner'] == payload['owner']
assert r['public_key_pem'] == payload['publicKeyPem']
@pytest.mark.parametrize('raw', [
'algorithm="test",keyid="badCase"',
'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)

View File

@ -1,36 +1,146 @@
from django.urls import reverse
from funkwhale_api.federation import keys
from funkwhale_api.federation import models
from funkwhale_api.federation import serializers
def test_repr_instance_actor(db, preferences, settings):
_, public_key = keys.get_key_pair()
preferences['federation__public_key'] = public_key.decode('utf-8')
settings.FEDERATION_HOSTNAME = 'test.federation'
settings.FUNKWHALE_URL = 'https://test.federation'
actor_url = settings.FUNKWHALE_URL + reverse('federation:instance-actor')
inbox_url = settings.FUNKWHALE_URL + reverse('federation:instance-inbox')
outbox_url = settings.FUNKWHALE_URL + reverse('federation:instance-outbox')
def test_actor_serializer_from_ap(db):
payload = {
'id': 'https://test.federation/user',
'type': 'Person',
'following': 'https://test.federation/user/following',
'followers': 'https://test.federation/user/followers',
'inbox': 'https://test.federation/user/inbox',
'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']
assert actor.followers_url == payload['followers']
assert actor.following_url == payload['following']
assert actor.public_key == payload['publicKey']['publicKeyPem']
assert actor.preferred_username == payload['preferredUsername']
assert actor.name == payload['name']
assert actor.domain == 'test.federation'
assert actor.summary == payload['summary']
assert actor.type == 'Person'
assert actor.manually_approves_followers == payload['manuallyApprovesFollowers']
def test_actor_serializer_only_mandatory_field_from_ap(db):
payload = {
'id': 'https://test.federation/user',
'type': 'Person',
'following': 'https://test.federation/user/following',
'followers': 'https://test.federation/user/followers',
'inbox': 'https://test.federation/user/inbox',
'outbox': 'https://test.federation/user/outbox',
'preferredUsername': 'user',
}
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.followers_url == payload['followers']
assert actor.following_url == payload['following']
assert actor.preferred_username == payload['preferredUsername']
assert actor.domain == 'test.federation'
assert actor.type == 'Person'
assert actor.manually_approves_followers is None
def test_actor_serializer_to_ap():
expected = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{},
],
'id': actor_url,
'type': 'Person',
'preferredUsername': 'service',
'name': 'Service Bot - test.federation',
'summary': 'Bot account for federating with test.federation',
'inbox': inbox_url,
'outbox': outbox_url,
'publicKey': {
'id': '{}#main-key'.format(actor_url),
'owner': actor_url,
'publicKeyPem': public_key.decode('utf-8')
},
'id': 'https://test.federation/user',
'type': 'Person',
'following': 'https://test.federation/user/following',
'followers': 'https://test.federation/user/followers',
'inbox': 'https://test.federation/user/inbox',
'outbox': 'https://test.federation/user/outbox',
'preferredUsername': 'user',
'name': 'Real User',
'summary': 'Hello world',
'manuallyApprovesFollowers': False,
'publicKey': {
'id': 'https://test.federation/user#main-key',
'owner': 'https://test.federation/user',
'publicKeyPem': 'yolo'
},
'endpoints': {
'sharedInbox': 'https://test.federation/inbox'
},
}
ac = models.Actor(
url=expected['id'],
inbox_url=expected['inbox'],
outbox_url=expected['outbox'],
shared_inbox_url=expected['endpoints']['sharedInbox'],
followers_url=expected['followers'],
following_url=expected['following'],
public_key=expected['publicKey']['publicKeyPem'],
preferred_username=expected['preferredUsername'],
name=expected['name'],
domain='test.federation',
summary=expected['summary'],
type='Person',
manually_approves_followers=False,
assert expected == serializers.repr_instance_actor()
)
serializer = serializers.ActorSerializer(ac)
assert serializer.data == expected
def test_webfinger_serializer():
expected = {
'subject': 'acct:service@test.federation',
'links': [
{
'rel': 'self',
'href': 'https://test.federation/federation/instance/actor',
'type': 'application/activity+json',
}
],
'aliases': [
'https://test.federation/federation/instance/actor',
]
}
actor = models.Actor(
url=expected['links'][0]['href'],
preferred_username='service',
domain='test.federation',
)
serializer = serializers.ActorWebfingerSerializer(actor)
assert serializer.data == expected

View File

@ -7,23 +7,23 @@ from funkwhale_api.federation import signing
from funkwhale_api.federation import keys
def test_can_sign_and_verify_request(factories):
private, public = factories['federation.KeyPair']()
auth = factories['federation.SignatureAuth'](key=private)
request = factories['federation.SignedRequest'](
def test_can_sign_and_verify_request(nodb_factories):
private, public = nodb_factories['federation.KeyPair']()
auth = nodb_factories['federation.SignatureAuth'](key=private)
request = nodb_factories['federation.SignedRequest'](
auth=auth
)
prepared_request = request.prepare()
assert 'date' in prepared_request.headers
assert 'authorization' in prepared_request.headers
assert prepared_request.headers['authorization'].startswith('Signature')
assert signing.verify(prepared_request, public) is None
assert 'signature' in prepared_request.headers
assert signing.verify(
prepared_request, public) is None
def test_can_sign_and_verify_request_digest(factories):
private, public = factories['federation.KeyPair']()
auth = factories['federation.SignatureAuth'](key=private)
request = factories['federation.SignedRequest'](
def test_can_sign_and_verify_request_digest(nodb_factories):
private, public = nodb_factories['federation.KeyPair']()
auth = nodb_factories['federation.SignatureAuth'](key=private)
request = nodb_factories['federation.SignedRequest'](
auth=auth,
method='post',
data=b'hello=world'
@ -31,70 +31,80 @@ def test_can_sign_and_verify_request_digest(factories):
prepared_request = request.prepare()
assert 'date' in prepared_request.headers
assert 'digest' in prepared_request.headers
assert 'authorization' in prepared_request.headers
assert prepared_request.headers['authorization'].startswith('Signature')
assert 'signature' in prepared_request.headers
assert signing.verify(prepared_request, public) is None
def test_verify_fails_with_wrong_key(factories):
wrong_private, wrong_public = factories['federation.KeyPair']()
request = factories['federation.SignedRequest']()
def test_verify_fails_with_wrong_key(nodb_factories):
wrong_private, wrong_public = nodb_factories['federation.KeyPair']()
request = nodb_factories['federation.SignedRequest']()
prepared_request = request.prepare()
with pytest.raises(cryptography.exceptions.InvalidSignature):
signing.verify(prepared_request, wrong_public)
def test_can_verify_django_request(factories, api_request):
def test_can_verify_django_request(factories, fake_request):
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key
auth__key=private_key,
auth__headers=[
'date',
]
)
prepared = signed_request.prepare()
django_request = api_request.get(
django_request = fake_request.get(
'/',
headers={
'Date': prepared.headers['date'],
'Authorization': prepared.headers['authorization'],
**{
'HTTP_DATE': prepared.headers['date'],
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
assert signing.verify_django(django_request, public_key) is None
def test_can_verify_django_request_digest(factories, api_request):
def test_can_verify_django_request_digest(factories, fake_request):
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key,
method='post',
data=b'hello=world'
data=b'hello=world',
auth__headers=[
'date',
'digest',
]
)
prepared = signed_request.prepare()
django_request = api_request.post(
django_request = fake_request.post(
'/',
headers={
'Date': prepared.headers['date'],
'Digest': prepared.headers['digest'],
'Authorization': prepared.headers['authorization'],
**{
'HTTP_DATE': prepared.headers['date'],
'HTTP_DIGEST': prepared.headers['digest'],
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
assert signing.verify_django(django_request, public_key) is None
def test_can_verify_django_request_digest_failure(factories, api_request):
def test_can_verify_django_request_digest_failure(factories, fake_request):
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key,
method='post',
data=b'hello=world'
data=b'hello=world',
auth__headers=[
'date',
'digest',
]
)
prepared = signed_request.prepare()
django_request = api_request.post(
django_request = fake_request.post(
'/',
headers={
'Date': prepared.headers['date'],
'Digest': prepared.headers['digest'] + 'noop',
'Authorization': prepared.headers['authorization'],
**{
'HTTP_DATE': prepared.headers['date'],
'HTTP_DIGEST': prepared.headers['digest'] + 'noop',
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
@ -102,17 +112,20 @@ def test_can_verify_django_request_digest_failure(factories, api_request):
signing.verify_django(django_request, public_key)
def test_can_verify_django_request_failure(factories, api_request):
def test_can_verify_django_request_failure(factories, fake_request):
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key
auth__key=private_key,
auth__headers=[
'date',
]
)
prepared = signed_request.prepare()
django_request = api_request.get(
django_request = fake_request.get(
'/',
headers={
'Date': 'Wrong',
'Authorization': prepared.headers['authorization'],
**{
'HTTP_DATE': 'Wrong',
'HTTP_SIGNATURE': prepared.headers['signature'],
}
)
with pytest.raises(cryptography.exceptions.InvalidSignature):

View File

@ -12,3 +12,37 @@ from funkwhale_api.federation import utils
def test_full_url(settings, url, path, expected):
settings.FUNKWHALE_URL = url
assert utils.full_url(path) == expected
def test_extract_headers_from_meta():
wsgi_headers = {
'HTTP_HOST': 'nginx',
'HTTP_X_REAL_IP': '172.20.0.4',
'HTTP_X_FORWARDED_FOR': '188.165.228.227, 172.20.0.4',
'HTTP_X_FORWARDED_PROTO': 'http',
'HTTP_X_FORWARDED_HOST': 'localhost:80',
'HTTP_X_FORWARDED_PORT': '80',
'HTTP_CONNECTION': 'close',
'CONTENT_LENGTH': '1155',
'CONTENT_TYPE': 'txt/application',
'HTTP_SIGNATURE': 'Hello',
'HTTP_DATE': 'Sat, 31 Mar 2018 13:53:55 GMT',
'HTTP_USER_AGENT': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'}
cleaned_headers = utils.clean_wsgi_headers(wsgi_headers)
expected = {
'Host': 'nginx',
'X-Real-Ip': '172.20.0.4',
'X-Forwarded-For': '188.165.228.227, 172.20.0.4',
'X-Forwarded-Proto': 'http',
'X-Forwarded-Host': 'localhost:80',
'X-Forwarded-Port': '80',
'Connection': 'close',
'Content-Length': '1155',
'Content-Type': 'txt/application',
'Signature': 'Hello',
'Date': 'Sat, 31 Mar 2018 13:53:55 GMT',
'User-Agent': 'http.rb/3.0.0 (Mastodon/2.2.0; +https://mastodon.eliotberriot.com/)'
}
assert cleaned_headers == expected

View File

@ -2,29 +2,35 @@ from django.urls import reverse
import pytest
from funkwhale_api.federation import actors
from funkwhale_api.federation import serializers
from funkwhale_api.federation import webfinger
def test_instance_actor(db, settings, api_client):
settings.FUNKWHALE_URL = 'http://test.com'
url = reverse('federation:instance-actor')
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_instance_actors(system_actor, db, settings, api_client):
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
url = reverse(
'federation:instance-actors-detail',
kwargs={'actor': system_actor})
response = api_client.get(url)
serializer = serializers.ActorSerializer(actor)
assert response.status_code == 200
assert response.data == serializers.repr_instance_actor()
assert response.data == serializer.data
@pytest.mark.parametrize('route', [
'instance-outbox',
'instance-inbox',
'instance-actor',
'well-known-webfinger',
@pytest.mark.parametrize('route,kwargs', [
('instance-actors-outbox', {'actor': 'library'}),
('instance-actors-inbox', {'actor': 'library'}),
('instance-actors-detail', {'actor': 'library'}),
('well-known-webfinger', {}),
])
def test_instance_inbox_405_if_federation_disabled(
db, settings, api_client, route):
def test_instance_endpoints_405_if_federation_disabled(
authenticated_actor, db, settings, api_client, route, kwargs):
settings.FEDERATION_ENABLED = False
url = reverse('federation:{}'.format(route))
url = reverse('federation:{}'.format(route), kwargs=kwargs)
response = api_client.get(url)
assert response.status_code == 405
@ -33,7 +39,6 @@ def test_instance_inbox_405_if_federation_disabled(
def test_wellknown_webfinger_validates_resource(
db, api_client, settings, mocker):
clean = mocker.spy(webfinger, 'clean_resource')
settings.FEDERATION_ENABLED = True
url = reverse('federation:well-known-webfinger')
response = api_client.get(url, data={'resource': 'something'})
@ -45,14 +50,15 @@ def test_wellknown_webfinger_validates_resource(
)
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_wellknown_webfinger_system(
db, api_client, settings, mocker):
settings.FEDERATION_ENABLED = True
settings.FEDERATION_HOSTNAME = 'test.federation'
system_actor, db, api_client, settings, mocker):
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
url = reverse('federation:well-known-webfinger')
response = api_client.get(
url, data={'resource': 'acct:service@test.federation'})
url, data={'resource': 'acct:{}'.format(actor.webfinger_subject)})
serializer = serializers.ActorWebfingerSerializer(actor)
assert response.status_code == 200
assert response['Content-Type'] == 'application/jrd+json'
assert response.data == webfinger.serialize_system_acct()
assert response.data == serializer.data

View File

@ -25,42 +25,18 @@ def test_webfinger_clean_resource_errors(resource, message):
def test_webfinger_clean_acct(settings):
settings.FEDERATION_HOSTNAME = 'test.federation'
username, hostname = webfinger.clean_acct('service@test.federation')
assert username == 'service'
username, hostname = webfinger.clean_acct('library@test.federation')
assert username == 'library'
assert hostname == 'test.federation'
@pytest.mark.parametrize('resource,message', [
('service', 'Invalid format'),
('service@test.com', 'Invalid hostname'),
('service@test.com', 'Invalid hostname test.com'),
('noop@test.federation', 'Invalid account'),
])
def test_webfinger_clean_acct_errors(resource, message, settings):
settings.FEDERATION_HOSTNAME = 'test.federation'
with pytest.raises(forms.ValidationError) as excinfo:
webfinger.clean_resource(resource)
assert message == str(excinfo)
def test_service_serializer(settings):
settings.FEDERATION_HOSTNAME = 'test.federation'
settings.FUNKWHALE_URL = 'https://test.federation'
expected = {
'subject': 'acct:service@test.federation',
'links': [
{
'rel': 'self',
'href': 'https://test.federation/federation/instance/actor',
'type': 'application/activity+json',
}
],
'aliases': [
'https://test.federation/federation/instance/actor',
]
}
assert expected == webfinger.serialize_system_acct()

View File

@ -0,0 +1 @@
Implemented a @test@yourfunkwhaledomain bot to ensure federation works properly. Send it "/ping" and it will answer back :)

10
dev.yml
View File

@ -38,13 +38,8 @@ services:
- redis
command: celery -A funkwhale_api.taskapp worker -l debug
environment:
- "DJANGO_ALLOWED_HOSTS=localhost"
- "DJANGO_SETTINGS_MODULE=config.settings.local"
- "DJANGO_SECRET_KEY=dev"
- C_FORCE_ROOT=true
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
- "FUNKWHALE_URL=http://localhost"
volumes:
- ./api:/app
- ./data/music:/music
@ -60,13 +55,8 @@ services:
- ./api:/app
- ./data/music:/music
environment:
- "PYTHONDONTWRITEBYTECODE=true"
- "DJANGO_ALLOWED_HOSTS=localhost,nginx"
- "DJANGO_SETTINGS_MODULE=config.settings.local"
- "DJANGO_SECRET_KEY=dev"
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
- "FUNKWHALE_URL=http://localhost"
links:
- postgres
- redis

View File

@ -1,8 +1,9 @@
#!/bin/bash -eux
FIRST_HOST=$(echo ${DJANGO_ALLOWED_HOSTS} | cut -d, -f1)
echo "Copying template file..."
cp /etc/nginx/funkwhale_proxy.conf{.template,}
sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host localhost:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FIRST_HOST}:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FIRST_HOST}/" /etc/nginx/funkwhale_proxy.conf
sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
cat /etc/nginx/funkwhale_proxy.conf