Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
interfect
funkwhale
Commits
0c8faf83
Verified
Commit
0c8faf83
authored
Mar 31, 2018
by
Eliot Berriot
Browse files
Can now have multiple system actors
We also handle webfinger/activity serialization properly
parent
6c3b7ce1
Changes
13
Hide whitespace changes
Inline
Side-by-side
api/funkwhale_api/federation/actors.py
0 → 100644
View file @
0c8faf83
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
),
}
api/funkwhale_api/federation/authentication.py
0 → 100644
View file @
0c8faf83
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
)
api/funkwhale_api/federation/keys.py
View file @
0c8faf83
...
...
@@ -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
api/funkwhale_api/federation/serializers.py
View file @
0c8faf83
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
)
class
Meta
:
model
=
models
.
Actor
fields
=
[
'id'
,
'type'
,
'name'
,
'summary'
,
'preferredUsername'
,
'publicKey'
,
'inbox'
,
'outbox'
,
'following'
,
'followers'
,
'manuallyApprovesFollowers'
,
]
return
{
'@context'
:
[
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
api/funkwhale_api/federation/urls.py
View file @
0c8faf83
...
...
@@ -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
.
Instance
Actor
ViewSet
,
'instance
-actors
'
)
router
.
register
(
r
'.well-known'
,
views
.
WellKnownViewSet
,
...
...
api/funkwhale_api/federation/views.py
View file @
0c8faf83
...
...
@@ -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
api/funkwhale_api/federation/webfinger.py
View file @
0c8faf83
...
...
@@ -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'
)),
}
]
}
api/tests/federation/test_actors.py
0 → 100644
View file @
0c8faf83
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
api/tests/federation/test_authentication.py
0 → 100644
View file @
0c8faf83
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
api/tests/federation/test_keys.py
View file @
0c8faf83
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
)
api/tests/federation/test_serializers.py
View file @
0c8faf83
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
,