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

Wyświetl plik

@ -19,3 +19,7 @@ recommonmark
# Some datetime magic
arrow
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.DiasporaHCard
.. autoclass:: federation.hostmeta.generators.NodeInfo
.. autoclass:: federation.hostmeta.generators.RFC3033Webfinger
.. autoclass:: federation.hostmeta.generators.SocialRelayWellKnown
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_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
---------
@ -107,10 +136,12 @@ Diaspora
........
.. 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_public_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_profile_diaspora_id
.. autofunction:: federation.utils.diaspora.parse_profile_from_hcard
.. autofunction:: federation.utils.diaspora.retrieve_and_parse_content
.. 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 os
import warnings
from base64 import b64encode
from string import Template
from jsonschema import validate
from jsonschema.exceptions import ValidationError
from xrd import XRD, Link, Element
from federation.utils.diaspora import parse_profile_diaspora_id
def generate_host_meta(template=None, *args, **kwargs):
"""Generate a host-meta XRD document.
@ -55,7 +56,7 @@ def generate_hcard(template=None, **kwargs):
return hcard.render()
class BaseHostMeta(object):
class BaseHostMeta:
def __init__(self, *args, **kwargs):
self.xrd = XRD()
@ -152,7 +153,7 @@ class DiasporaWebFinger(BaseLegacyWebFinger):
))
class DiasporaHCard(object):
class DiasporaHCard:
"""Diaspora hCard document.
Must receive the `required` attributes as keyword arguments to init.
@ -178,7 +179,7 @@ class DiasporaHCard(object):
return self.template.substitute(self.kwargs)
class SocialRelayWellKnown(object):
class SocialRelayWellKnown:
"""A `.well-known/social-relay` document in JSON.
For apps wanting to announce their preferences towards relay applications.
@ -209,7 +210,7 @@ class SocialRelayWellKnown(object):
validate(self.doc, schema)
class NodeInfo(object):
class NodeInfo:
"""Generate a NodeInfo document.
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
from jsonschema import validate, ValidationError
import pytest
from federation.hostmeta.generators import generate_host_meta, generate_legacy_webfinger, generate_hcard, \
SocialRelayWellKnown, NodeInfo, get_nodeinfo_well_known_document
from federation.hostmeta.generators import (
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
class TestDiasporaHostMetaGenerator(object):
class TestDiasporaHostMetaGenerator:
def test_generate_valid_host_meta(self):
hostmeta = generate_host_meta("diaspora", webfinger_host="https://example.com")
assert hostmeta.decode("UTF-8") == DIASPORA_HOSTMETA
@ -19,8 +19,7 @@ class TestDiasporaHostMetaGenerator(object):
generate_host_meta("diaspora")
class TestDiasporaWebFingerGenerator(object):
class TestDiasporaWebFingerGenerator:
def test_generate_valid_webfinger(self):
webfinger = generate_legacy_webfinger(
"diaspora",
@ -36,8 +35,7 @@ class TestDiasporaWebFingerGenerator(object):
generate_legacy_webfinger("diaspora")
class TestDiasporaHCardGenerator(object):
class TestDiasporaHCardGenerator:
def test_generate_valid_hcard(self):
with open("federation/hostmeta/templates/hcard_diaspora.html") as f:
template = f.read().replace("$", "")
@ -77,8 +75,7 @@ class TestDiasporaHCardGenerator(object):
)
class TestSocialRelayWellKnownGenerator(object):
class TestSocialRelayWellKnownGenerator:
def test_valid_social_relay_well_known(self):
with open("federation/hostmeta/schemas/social-relay-well-known.json") as f:
schema = json.load(f)
@ -125,7 +122,7 @@ class TestSocialRelayWellKnownGenerator(object):
well_known.render()
class TestNodeInfoGenerator(object):
class TestNodeInfoGenerator:
def _valid_nodeinfo(self, raise_on_validate=False):
return NodeInfo(
software={"name": "diaspora", "version": "0.5.4.3"},
@ -179,3 +176,25 @@ class TestNodeInfoGenerator(object):
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]["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,
_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,
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:
@ -48,6 +49,16 @@ def test_parse_diaspora_uri():
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:
@patch("federation.utils.diaspora.retrieve_and_parse_diaspora_webfinger", return_value={
"hcard_url": "http://localhost",

Wyświetl plik

@ -151,6 +151,25 @@ def parse_diaspora_uri(uri):
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):
"""
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):
"""Get remote endpoint for delivering private payloads."""
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
)
handle, guid = parse_profile_diaspora_id(id)
_username, domain = handle.split("@")
return "https://%s/receive/users/%s" % (domain, guid)

Wyświetl plik

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