From 174fcfedbc0642907c4d3137dc77a2e3b3031a0e Mon Sep 17 00:00:00 2001 From: Nick Krichevsky Date: Mon, 10 Jul 2023 10:36:52 -0400 Subject: [PATCH] Add basic webfinger implementation for local users --- poetry.lock | 88 ++++++++++++++++++- printpub/settings.py | 4 + printpub/urls.py | 6 +- printpub/user/models/local_user.py | 4 +- printpub/user/serializers/__init__.py | 1 + printpub/user/serializers/webfinger_user.py | 32 +++++++ printpub/user/urls.py | 4 + printpub/user/views.py | 3 - printpub/user/views/__init__.py | 1 + printpub/user/views/webfinger.py | 55 ++++++++++++ pyproject.toml | 7 ++ tests/conftest.py | 6 ++ tests/user/serializers/webfinger_user_test.py | 17 ++++ tests/user/views/webfinger_test.py | 59 +++++++++++++ 14 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 printpub/user/serializers/__init__.py create mode 100644 printpub/user/serializers/webfinger_user.py create mode 100644 printpub/user/urls.py delete mode 100644 printpub/user/views.py create mode 100644 printpub/user/views/__init__.py create mode 100644 printpub/user/views/webfinger.py create mode 100644 tests/conftest.py create mode 100644 tests/user/serializers/webfinger_user_test.py create mode 100644 tests/user/views/webfinger_test.py diff --git a/poetry.lock b/poetry.lock index 3a9d7da..dc639c6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,6 +14,17 @@ files = [ [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + [[package]] name = "django" version = "4.2.3" @@ -49,6 +60,17 @@ files = [ django = ">=3.0" pytz = "*" +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "markdown" version = "3.4.3" @@ -63,6 +85,70 @@ files = [ [package.extras] testing = ["coverage", "pyyaml"] +[[package]] +name = "packaging" +version = "23.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, +] + +[[package]] +name = "pluggy" +version = "1.2.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "7.4.0" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-django" +version = "4.5.2" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.5" +files = [ + {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"}, + {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"}, +] + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["Django", "django-configurations (>=2.0)"] + [[package]] name = "pytz" version = "2023.3" @@ -104,4 +190,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "f61b53fbb29d79eabae5ac069b8a112d3a5f10e46092341f9c81c5ba506e4a57" +content-hash = "01c39e695348ecc6d61f10f3811b382ecd6f0d3acfa65f3c3e374c62bd1937f4" diff --git a/printpub/settings.py b/printpub/settings.py index a9cf527..9cf881c 100644 --- a/printpub/settings.py +++ b/printpub/settings.py @@ -27,6 +27,9 @@ DEBUG = True ALLOWED_HOSTS = [] +# TODO: This is a bit of a hack for development, this id may change as the project progresses +SITE_ID = 1 + # Application definition @@ -37,6 +40,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "django.contrib.sites", "rest_framework", "printpub.user.apps.UserConfig", ] diff --git a/printpub/urls.py b/printpub/urls.py index 16a74d2..12cd421 100644 --- a/printpub/urls.py +++ b/printpub/urls.py @@ -15,8 +15,10 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include +import printpub.user.urls urlpatterns = [ - path('admin/', admin.site.urls), + path("", include("printpub.user.urls")), + path("admin/", admin.site.urls), ] diff --git a/printpub/user/models/local_user.py b/printpub/user/models/local_user.py index c98ca51..401c2b2 100644 --- a/printpub/user/models/local_user.py +++ b/printpub/user/models/local_user.py @@ -14,7 +14,9 @@ class LocalUser(auth_models.AbstractUser): first_name = None last_name = None display_name = models.CharField(max_length=128) - poster = models.OneToOneField(poster.Poster, on_delete=models.RESTRICT) + poster = models.OneToOneField( + poster.Poster, on_delete=models.RESTRICT, related_name="local_user" + ) def get_full_name(self) -> str: """ diff --git a/printpub/user/serializers/__init__.py b/printpub/user/serializers/__init__.py new file mode 100644 index 0000000..d81f20c --- /dev/null +++ b/printpub/user/serializers/__init__.py @@ -0,0 +1 @@ +from printpub.user.serializers.webfinger_user import WebfingerUserSerializer diff --git a/printpub/user/serializers/webfinger_user.py b/printpub/user/serializers/webfinger_user.py new file mode 100644 index 0000000..ba58c37 --- /dev/null +++ b/printpub/user/serializers/webfinger_user.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from printpub.user import models + +DOMAIN_REQUIRED_MESSAGE = ( + "The domain is required as part of the context of this serializer" +) + + +class _SubjectField(serializers.Field): + def to_representation(self, username: str): + domain = self.context.get("domain") + if domain is None: + raise ValueError(DOMAIN_REQUIRED_MESSAGE) + + return f"acct:{username}@{domain}" + + +class WebfingerUserSerializer(serializers.Serializer): + """ + WebfingerUserSerializer will generate a JSON LD representation of a user for the Webfinger protocol. + + It is required that a 'domain' property be passed to the context of this seiralizer, so that proper + resources can be generated. + """ + + subject = _SubjectField(source="username", read_only=True) + links = serializers.SerializerMethodField() + + def get_links(self, _): + # TODO: we hardcode this to an empty array because we have no way to "get a user" right now. + # This is valid per the webfinger rfc + return [] diff --git a/printpub/user/urls.py b/printpub/user/urls.py new file mode 100644 index 0000000..49093eb --- /dev/null +++ b/printpub/user/urls.py @@ -0,0 +1,4 @@ +from django.urls import path +from printpub.user import views + +urlpatterns = [path(".well-known/webfinger", views.WebFinger.as_view())] diff --git a/printpub/user/views.py b/printpub/user/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/printpub/user/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/printpub/user/views/__init__.py b/printpub/user/views/__init__.py new file mode 100644 index 0000000..33feb68 --- /dev/null +++ b/printpub/user/views/__init__.py @@ -0,0 +1 @@ +from printpub.user.views.webfinger import WebFinger diff --git a/printpub/user/views/webfinger.py b/printpub/user/views/webfinger.py new file mode 100644 index 0000000..7ce8e89 --- /dev/null +++ b/printpub/user/views/webfinger.py @@ -0,0 +1,55 @@ +import re + +import django.contrib.sites.shortcuts as django_sites +import rest_framework.views +import rest_framework.request +import rest_framework.response +import rest_framework.status + +from printpub.user import models +from printpub.user import serializers + +from typing import Optional + + +class WebFinger(rest_framework.views.APIView): + def get(self, request: rest_framework.request.Request): + resource_param = request.query_params.get("resource") + if not resource_param: + return rest_framework.response.Response( + status=rest_framework.status.HTTP_400_BAD_REQUEST + ) + + parsed_resource = self._parse_acct_string(resource_param) + if not parsed_resource: + return rest_framework.response.Response( + status=rest_framework.status.HTTP_400_BAD_REQUEST + ) + + (local_part, domain_part) = parsed_resource + try: + user = models.LocalUser.objects.get(username=local_part) + except models.LocalUser.DoesNotExist: + return rest_framework.response.Response( + status=rest_framework.status.HTTP_404_NOT_FOUND + ) + + site = django_sites.get_current_site(request) + if domain_part != site.domain: + return rest_framework.response.Response( + status=rest_framework.status.HTTP_404_NOT_FOUND + ) + + user_serializer = serializers.WebfingerUserSerializer( + user, context={"domain": domain_part} + ) + + return rest_framework.response.Response(user_serializer.data) + + @staticmethod + def _parse_acct_string(acct: str) -> Optional[tuple[str, str]]: + matches = re.match("^acct:(?P[^@]+)@(?P.+)$", acct) + if not matches: + return None + + return (matches.group("local"), matches.group("domain")) diff --git a/pyproject.toml b/pyproject.toml index 8e0b778..85f0a77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,13 @@ djangorestframework = "^3.14.0" markdown = "^3.4.3" +[tool.poetry.group.dev.dependencies] +pytest-django = "^4.5.2" +pytest = "^7.4.0" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "printpub.settings" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0e0e72c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture(autouse=True) +def enable_db_access_for_all_tests(db): + pass diff --git a/tests/user/serializers/webfinger_user_test.py b/tests/user/serializers/webfinger_user_test.py new file mode 100644 index 0000000..29e8f38 --- /dev/null +++ b/tests/user/serializers/webfinger_user_test.py @@ -0,0 +1,17 @@ +import unittest.mock + +from printpub.user import models +from printpub.user import serializers + + +def test_serializes_user_with_no_links(): + poster = models.Poster() + poster.save() + user = models.LocalUser.objects.create_user( + username="wint", password="hunter2", display_name="dril", poster=poster + ) + + serializer = serializers.WebfingerUserSerializer( + user, context={"domain": "example.com"} + ) + assert serializer.data == {"subject": "acct:wint@example.com", "links": []} diff --git a/tests/user/views/webfinger_test.py b/tests/user/views/webfinger_test.py new file mode 100644 index 0000000..6069735 --- /dev/null +++ b/tests/user/views/webfinger_test.py @@ -0,0 +1,59 @@ +import unittest.mock + +import django.contrib.sites.models +import rest_framework.test +import rest_framework.response + +from printpub.user import models + + +class TestWebfingerGet: + def test_request_with_no_resource_gives_400(self): + client = rest_framework.test.APIClient() + res = client.get("/.well-known/webfinger") + assert res.status_code == 400 # type: ignore + + def test_request_with_unknown_user_returns_404(self): + client = rest_framework.test.APIClient() + res = client.get("/.well-known/webfinger?resource=acct:wint@my.website") + assert res.status_code == 404 # type: ignore + + def test_known_user_returns_serializer_data(self): + client = rest_framework.test.APIClient() + poster = models.Poster() + poster.save() + user = models.LocalUser( + display_name="dril", username="wint", password="hunter2", poster=poster + ) + user.save() + + fake_site = django.contrib.sites.models.Site( + domain="my.website", name="My Website" + ) + with unittest.mock.patch( + "django.contrib.sites.models.SiteManager.get_current", + return_value=fake_site, + ): + res = client.get("/.well-known/webfinger?resource=acct:wint@my.website") + assert res.status_code == 200 # type: ignore + # We could test more properties of this, but we will leave that to the serializer test + assert res.data["subject"] == "acct:wint@my.website" # type: ignore + + def test_wrong_domain_in_request_returns_404(self): + client = rest_framework.test.APIClient() + poster = models.Poster() + poster.save() + user = models.LocalUser( + display_name="dril", username="wint", password="hunter2", poster=poster + ) + user.save() + + fake_site = django.contrib.sites.models.Site( + domain="my.website", name="My Website" + ) + with unittest.mock.patch( + "django.contrib.sites.models.SiteManager.get_current", + return_value=fake_site, + ): + res = client.get("/.well-known/webfinger?resource=acct:wint@example.com") + assert res.status_code == 404 # type: ignore