Add RFC3033 webfinger generator

Also provided is a Django view and url configuration for easy addition into Django projects. Django is not a hard dependency of this library, usage of the Django view obviously requires installing Django itself. For configuration details see documentation.

Closes #108
merge-requests/130/head
Jason Robinson 2018-02-15 18:44:18 +02:00
rodzic 456d8344d8
commit f80211b178
15 zmienionych plików z 292 dodań i 28 usunięć

Wyświetl plik

@ -7,6 +7,10 @@
* Enable generating encrypted JSON payloads with the Diaspora protocol which adds private message support. ([related issue](https://github.com/jaywink/federation/issues/82)) * Enable generating encrypted JSON payloads with the Diaspora protocol which adds private message support. ([related issue](https://github.com/jaywink/federation/issues/82))
JSON encrypted payload encryption and decryption is handled by the Diaspora `EncryptedPayload` class. JSON encrypted payload encryption and decryption is handled by the Diaspora `EncryptedPayload` class.
* Add RFC3033 webfinger generator ([related issue](https://github.com/jaywink/federation/issues/108))
Also provided is a Django view and url configuration for easy addition into Django projects. Django is not a hard dependency of this library, usage of the Django view obviously requires installing Django itself. For configuration details see documentation.
### Changed ### Changed

Wyświetl plik

@ -19,3 +19,7 @@ recommonmark
# Some datetime magic # Some datetime magic
arrow arrow
freezegun freezegun
# Django support
django>=1.8,<2.1
pytest-django

Wyświetl plik

@ -58,9 +58,9 @@ Generator classes
.. autoclass:: federation.hostmeta.generators.DiasporaWebFinger .. autoclass:: federation.hostmeta.generators.DiasporaWebFinger
.. autoclass:: federation.hostmeta.generators.DiasporaHCard .. autoclass:: federation.hostmeta.generators.DiasporaHCard
.. autoclass:: federation.hostmeta.generators.NodeInfo .. autoclass:: federation.hostmeta.generators.NodeInfo
.. autoclass:: federation.hostmeta.generators.RFC3033Webfinger
.. autoclass:: federation.hostmeta.generators.SocialRelayWellKnown .. autoclass:: federation.hostmeta.generators.SocialRelayWellKnown
Fetchers Fetchers
-------- --------
@ -87,6 +87,35 @@ High level utility functions to pass outbound entities to. These should be favou
.. autofunction:: federation.outbound.handle_create_payload .. autofunction:: federation.outbound.handle_create_payload
.. autofunction:: federation.outbound.handle_send .. autofunction:: federation.outbound.handle_send
Django
------
Some ready provided views and URL configuration exist for Django.
Note! Django is not part of the normal requirements for this library. It must be installed separately.
.. autofunction:: federation.hostmeta.django.generators.rfc3033_webfinger_view
Configuration
.............
To use the Django views, ensure a modern version of Django is installed and add the views to your URL config for example as follows. The URL's must be mounted on root if Diaspora protocol support is required.
::
url(r"", include("federation.hostmeta.django.urls")),
Some settings need to be set in Django settings. An example is below:
::
FEDERATION = {
"base_url": "https://myserver.domain.tld,
"profile_id_function": "myproject.utils.get_profile_id_by_handle",
}
* ``base_url`` is the base URL of the server, ie protocol://domain.tld.
* ``profile_id_function`` should be the full path to a function that given a handle will return the Diaspora URI format profile ID.
Protocols Protocols
--------- ---------
@ -107,10 +136,12 @@ Diaspora
........ ........
.. autofunction:: federation.utils.diaspora.fetch_public_key .. autofunction:: federation.utils.diaspora.fetch_public_key
.. autofunction:: federation.utils.diaspora.generate_diaspora_profile_id
.. autofunction:: federation.utils.diaspora.get_fetch_content_endpoint .. autofunction:: federation.utils.diaspora.get_fetch_content_endpoint
.. autofunction:: federation.utils.diaspora.get_public_endpoint
.. autofunction:: federation.utils.diaspora.get_private_endpoint .. autofunction:: federation.utils.diaspora.get_private_endpoint
.. autofunction:: federation.utils.diaspora.get_public_endpoint
.. autofunction:: federation.utils.diaspora.parse_diaspora_uri .. autofunction:: federation.utils.diaspora.parse_diaspora_uri
.. autofunction:: federation.utils.diaspora.parse_profile_diaspora_id
.. autofunction:: federation.utils.diaspora.parse_profile_from_hcard .. autofunction:: federation.utils.diaspora.parse_profile_from_hcard
.. autofunction:: federation.utils.diaspora.retrieve_and_parse_content .. autofunction:: federation.utils.diaspora.retrieve_and_parse_content
.. autofunction:: federation.utils.diaspora.retrieve_and_parse_profile .. autofunction:: federation.utils.diaspora.retrieve_and_parse_profile

Wyświetl plik

@ -0,0 +1 @@
from .generators import rfc3033_webfinger_view

Wyświetl plik

@ -0,0 +1,71 @@
import importlib
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseBadRequest, JsonResponse, HttpResponseNotFound
from federation.hostmeta.generators import RFC3033Webfinger
logger = logging.getLogger("federation")
def get_configuration():
"""
Combine defaults with the Django configuration.
"""
configuration = {
"hcard_path": "/hcard/users/",
}
configuration.update(settings.FEDERATION)
if not all([
"profile_id_function" in configuration,
"base_url" in configuration,
"hcard_path" in configuration,
]):
raise ImproperlyConfigured("Missing required FEDERATION settings, please check documentation.")
return configuration
def get_profile_id_func():
"""
Import the function to get profile ID by handle.
"""
config = get_configuration()
profile_func_path = config.get("profile_id_function")
module_path, func_name = profile_func_path.rsplit(".", 1)
module = importlib.import_module(module_path)
profile_func = getattr(module, func_name)
return profile_func
def rfc3033_webfinger_view(request, *args, **kwargs):
"""
Django view to generate an RFC3033 webfinger.
"""
resource = request.GET.get("resource")
if not resource:
return HttpResponseBadRequest("No resource found")
if not resource.startswith("acct:"):
return HttpResponseBadRequest("Invalid resource")
handle = resource.replace("acct:", "")
profile_id_func = get_profile_id_func()
try:
profile_id = profile_id_func(handle)
except Exception as exc:
logger.warning("rfc3033_webfinger_view - Failed to get profile ID by handle %s: %s", handle, exc)
return HttpResponseNotFound()
config = get_configuration()
webfinger = RFC3033Webfinger(
id=profile_id,
base_url=config.get('base_url'),
hcard_path=config.get('hcard_path'),
)
return JsonResponse(
webfinger.render(),
content_type="application/jrd+json",
)

Wyświetl plik

@ -0,0 +1,7 @@
from django.conf.urls import url
from federation.hostmeta.django import rfc3033_webfinger_view
urlpatterns = [
url(r'^.well-known/webfinger$', rfc3033_webfinger_view, name="rfc3033-webfinger"),
]

Wyświetl plik

@ -1,14 +1,15 @@
# -*- coding: utf-8 -*-
import warnings
from base64 import b64encode
import json import json
import os import os
import warnings
from base64 import b64encode
from string import Template from string import Template
from jsonschema import validate from jsonschema import validate
from jsonschema.exceptions import ValidationError from jsonschema.exceptions import ValidationError
from xrd import XRD, Link, Element from xrd import XRD, Link, Element
from federation.utils.diaspora import parse_profile_diaspora_id
def generate_host_meta(template=None, *args, **kwargs): def generate_host_meta(template=None, *args, **kwargs):
"""Generate a host-meta XRD document. """Generate a host-meta XRD document.
@ -55,7 +56,7 @@ def generate_hcard(template=None, **kwargs):
return hcard.render() return hcard.render()
class BaseHostMeta(object): class BaseHostMeta:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.xrd = XRD() self.xrd = XRD()
@ -152,7 +153,7 @@ class DiasporaWebFinger(BaseLegacyWebFinger):
)) ))
class DiasporaHCard(object): class DiasporaHCard:
"""Diaspora hCard document. """Diaspora hCard document.
Must receive the `required` attributes as keyword arguments to init. Must receive the `required` attributes as keyword arguments to init.
@ -178,7 +179,7 @@ class DiasporaHCard(object):
return self.template.substitute(self.kwargs) return self.template.substitute(self.kwargs)
class SocialRelayWellKnown(object): class SocialRelayWellKnown:
"""A `.well-known/social-relay` document in JSON. """A `.well-known/social-relay` document in JSON.
For apps wanting to announce their preferences towards relay applications. For apps wanting to announce their preferences towards relay applications.
@ -209,7 +210,7 @@ class SocialRelayWellKnown(object):
validate(self.doc, schema) validate(self.doc, schema)
class NodeInfo(object): class NodeInfo:
"""Generate a NodeInfo document. """Generate a NodeInfo document.
See spec: http://nodeinfo.diaspora.software See spec: http://nodeinfo.diaspora.software
@ -276,3 +277,37 @@ def get_nodeinfo_well_known_document(url, document_path=None):
} }
] ]
} }
class RFC3033Webfinger:
"""
RFC 3033 webfinger - see https://diaspora.github.io/diaspora_federation/discovery/webfinger.html
A Django view is also available, see the child ``django`` module for view and url configuration.
:param id: Diaspora ID in URI format
:param base_url: The base URL of the server (protocol://domain.tld)
:param hcard_path: (Optional) hCard path, defaults to ``/hcard/users/``.
:returns: dict
"""
def __init__(self, id, base_url, hcard_path="/hcard/users/"):
self.handle, self.guid = parse_profile_diaspora_id(id)
self.base_url = base_url
self.hcard_path = hcard_path
def render(self):
return {
"subject": "acct:%s" % self.handle,
"links": [
{
"rel": "http://microformats.org/profile/hcard",
"type": "text/html",
"href": "%s%s%s" % (self.base_url, self.hcard_path, self.guid),
},
{
"rel": "http://joindiaspora.com/seed_location",
"type": "text/html",
"href": self.base_url,
},
],
}

Wyświetl plik

@ -0,0 +1,8 @@
SECRET_KEY = "foobar"
INSTALLED_APPS = tuple()
FEDERATION = {
"base_url": "https://example.com",
"profile_id_function": "federation.tests.hostmeta.django.utils.get_profile_id_by_handle",
}

Wyświetl plik

@ -0,0 +1,52 @@
import json
from unittest.mock import patch, Mock
from django.test import RequestFactory
from federation.hostmeta.django import rfc3033_webfinger_view
from federation.hostmeta.django.generators import get_profile_id_func
def test_get_profile_id_func():
func = get_profile_id_func()
assert callable(func)
class TestRFC3033WebfingerView:
def test_no_resource_returns_bad_request(self):
request = RequestFactory().get("/.well-known/webfinger")
response = rfc3033_webfinger_view(request)
assert response.status_code == 400
def test_invalid_resource_returns_bad_request(self):
request = RequestFactory().get("/.well-known/webfinger?resource=foobar")
response = rfc3033_webfinger_view(request)
assert response.status_code == 400
@patch("federation.hostmeta.django.generators.get_profile_id_func")
def test_unknown_handle_returns_not_found(self, mock_get_func):
mock_get_func.return_value = Mock(side_effect=Exception)
request = RequestFactory().get("/.well-known/webfinger?resource=acct:foobar@domain.tld")
response = rfc3033_webfinger_view(request)
assert response.status_code == 404
def test_rendered_webfinger_returned(self):
request = RequestFactory().get("/.well-known/webfinger?resource=acct:foobar@example.com")
response = rfc3033_webfinger_view(request)
assert response.status_code == 200
assert response['Content-Type'] == "application/jrd+json"
assert json.loads(response.content.decode("utf-8")) == {
"subject": "acct:foobar@example.com",
"links": [
{
"rel": "http://microformats.org/profile/hcard",
"type": "text/html",
"href": "https://example.com/hcard/users/1234",
},
{
"rel": "http://joindiaspora.com/seed_location",
"type": "text/html",
"href": "https://example.com",
},
],
}

Wyświetl plik

@ -0,0 +1,5 @@
from federation.utils.diaspora import generate_diaspora_profile_id
def get_profile_id_by_handle(handle):
return generate_diaspora_profile_id(handle, "1234")

Wyświetl plik

@ -1,15 +1,15 @@
# -*- coding: utf-8 -*-
import json import json
from jsonschema import validate, ValidationError from jsonschema import validate, ValidationError
import pytest import pytest
from federation.hostmeta.generators import generate_host_meta, generate_legacy_webfinger, generate_hcard, \ from federation.hostmeta.generators import (
SocialRelayWellKnown, NodeInfo, get_nodeinfo_well_known_document generate_host_meta, generate_legacy_webfinger, generate_hcard,
SocialRelayWellKnown, NodeInfo, get_nodeinfo_well_known_document, RFC3033Webfinger,
)
from federation.tests.fixtures.payloads import DIASPORA_HOSTMETA, DIASPORA_WEBFINGER from federation.tests.fixtures.payloads import DIASPORA_HOSTMETA, DIASPORA_WEBFINGER
class TestDiasporaHostMetaGenerator(object): class TestDiasporaHostMetaGenerator:
def test_generate_valid_host_meta(self): def test_generate_valid_host_meta(self):
hostmeta = generate_host_meta("diaspora", webfinger_host="https://example.com") hostmeta = generate_host_meta("diaspora", webfinger_host="https://example.com")
assert hostmeta.decode("UTF-8") == DIASPORA_HOSTMETA assert hostmeta.decode("UTF-8") == DIASPORA_HOSTMETA
@ -19,8 +19,7 @@ class TestDiasporaHostMetaGenerator(object):
generate_host_meta("diaspora") generate_host_meta("diaspora")
class TestDiasporaWebFingerGenerator(object): class TestDiasporaWebFingerGenerator:
def test_generate_valid_webfinger(self): def test_generate_valid_webfinger(self):
webfinger = generate_legacy_webfinger( webfinger = generate_legacy_webfinger(
"diaspora", "diaspora",
@ -36,8 +35,7 @@ class TestDiasporaWebFingerGenerator(object):
generate_legacy_webfinger("diaspora") generate_legacy_webfinger("diaspora")
class TestDiasporaHCardGenerator(object): class TestDiasporaHCardGenerator:
def test_generate_valid_hcard(self): def test_generate_valid_hcard(self):
with open("federation/hostmeta/templates/hcard_diaspora.html") as f: with open("federation/hostmeta/templates/hcard_diaspora.html") as f:
template = f.read().replace("$", "") template = f.read().replace("$", "")
@ -77,8 +75,7 @@ class TestDiasporaHCardGenerator(object):
) )
class TestSocialRelayWellKnownGenerator(object): class TestSocialRelayWellKnownGenerator:
def test_valid_social_relay_well_known(self): def test_valid_social_relay_well_known(self):
with open("federation/hostmeta/schemas/social-relay-well-known.json") as f: with open("federation/hostmeta/schemas/social-relay-well-known.json") as f:
schema = json.load(f) schema = json.load(f)
@ -125,7 +122,7 @@ class TestSocialRelayWellKnownGenerator(object):
well_known.render() well_known.render()
class TestNodeInfoGenerator(object): class TestNodeInfoGenerator:
def _valid_nodeinfo(self, raise_on_validate=False): def _valid_nodeinfo(self, raise_on_validate=False):
return NodeInfo( return NodeInfo(
software={"name": "diaspora", "version": "0.5.4.3"}, software={"name": "diaspora", "version": "0.5.4.3"},
@ -179,3 +176,25 @@ class TestNodeInfoGenerator(object):
wellknown = get_nodeinfo_well_known_document("https://example.com") wellknown = get_nodeinfo_well_known_document("https://example.com")
assert wellknown["links"][0]["rel"] == "http://nodeinfo.diaspora.software/ns/schema/1.0" assert wellknown["links"][0]["rel"] == "http://nodeinfo.diaspora.software/ns/schema/1.0"
assert wellknown["links"][0]["href"] == "https://example.com/nodeinfo/1.0" assert wellknown["links"][0]["href"] == "https://example.com/nodeinfo/1.0"
def test_rfc3033_webfinger():
webfinger = RFC3033Webfinger(
"diaspora://foobar@example.com/profile/1234",
"https://example.com",
).render()
assert webfinger == {
"subject": "acct:foobar@example.com",
"links": [
{
"rel": "http://microformats.org/profile/hcard",
"type": "text/html",
"href": "https://example.com/hcard/users/1234",
},
{
"rel": "http://joindiaspora.com/seed_location",
"type": "text/html",
"href": "https://example.com",
},
],
}

Wyświetl plik

@ -11,7 +11,8 @@ from federation.utils.diaspora import (
retrieve_diaspora_hcard, retrieve_diaspora_host_meta, _get_element_text_or_none, retrieve_diaspora_hcard, retrieve_diaspora_host_meta, _get_element_text_or_none,
_get_element_attr_or_none, parse_profile_from_hcard, retrieve_and_parse_profile, retrieve_and_parse_content, _get_element_attr_or_none, parse_profile_from_hcard, retrieve_and_parse_profile, retrieve_and_parse_content,
get_fetch_content_endpoint, fetch_public_key, parse_diaspora_uri, get_fetch_content_endpoint, fetch_public_key, parse_diaspora_uri,
retrieve_and_parse_diaspora_webfinger, parse_diaspora_webfinger, get_public_endpoint, get_private_endpoint) retrieve_and_parse_diaspora_webfinger, parse_diaspora_webfinger, get_public_endpoint, get_private_endpoint,
parse_profile_diaspora_id, generate_diaspora_profile_id)
class TestParseDiasporaWebfinger: class TestParseDiasporaWebfinger:
@ -48,6 +49,16 @@ def test_parse_diaspora_uri():
assert not parse_diaspora_uri("spam and eggs") assert not parse_diaspora_uri("spam and eggs")
def test_parse_profile_diaspora_id():
assert parse_profile_diaspora_id("diaspora://foobar@example.com/profile/1234") == ("foobar@example.com", "1234")
with pytest.raises(ValueError):
assert parse_profile_diaspora_id("diaspora://foobar@example.com/like/1234")
def test_generate_diaspora_profile_id():
assert generate_diaspora_profile_id("foobar@example.com", "1234") == "diaspora://foobar@example.com/profile/1234"
class TestRetrieveDiasporaHCard: class TestRetrieveDiasporaHCard:
@patch("federation.utils.diaspora.retrieve_and_parse_diaspora_webfinger", return_value={ @patch("federation.utils.diaspora.retrieve_and_parse_diaspora_webfinger", return_value={
"hcard_url": "http://localhost", "hcard_url": "http://localhost",

Wyświetl plik

@ -151,6 +151,25 @@ def parse_diaspora_uri(uri):
return handle, entity_type, guid return handle, entity_type, guid
def parse_profile_diaspora_id(id):
"""
Parse profile handle and guid from diaspora ID.
"""
handle, entity_type, guid = parse_diaspora_uri(id)
if entity_type != "profile":
raise ValueError(
"Invalid entity type %s to generate private remote endpoint for delivery. Must be 'profile'." % entity_type
)
return handle, guid
def generate_diaspora_profile_id(handle, guid):
"""
Generate a Diaspora profile ID from handle and guid.
"""
return "diaspora://%s/profile/%s" % (handle, guid)
def parse_profile_from_hcard(hcard, handle): def parse_profile_from_hcard(hcard, handle):
""" """
Parse all the fields we can from a hCard document to get a Profile. Parse all the fields we can from a hCard document to get a Profile.
@ -244,10 +263,6 @@ def get_public_endpoint(id):
def get_private_endpoint(id): def get_private_endpoint(id):
"""Get remote endpoint for delivering private payloads.""" """Get remote endpoint for delivering private payloads."""
handle, entity_type, guid = parse_diaspora_uri(id) handle, guid = parse_profile_diaspora_id(id)
if entity_type != "profile":
raise ValueError(
"Invalid entity type %s to generate private remote endpoint for delivery. Must be 'profile'." % entity_type
)
_username, domain = handle.split("@") _username, domain = handle.split("@")
return "https://%s/receive/users/%s" % (domain, guid) return "https://%s/receive/users/%s" % (domain, guid)

Wyświetl plik

@ -1,2 +1,3 @@
[pytest] [pytest]
testpaths = federation testpaths = federation
DJANGO_SETTINGS_MODULE = federation.tests.hostmeta.django.settings