Add basic webfinger implementation for local users

master
Nick Krichevsky 2023-07-10 10:36:52 -04:00
parent 422f6556a2
commit 174fcfedbc
14 changed files with 280 additions and 7 deletions

88
poetry.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
from printpub.user.serializers.webfinger_user import WebfingerUserSerializer

View File

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

4
printpub/user/urls.py Normal file
View File

@ -0,0 +1,4 @@
from django.urls import path
from printpub.user import views
urlpatterns = [path(".well-known/webfinger", views.WebFinger.as_view())]

View File

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

@ -0,0 +1 @@
from printpub.user.views.webfinger import WebFinger

View File

@ -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<local>[^@]+)@(?P<domain>.+)$", acct)
if not matches:
return None
return (matches.group("local"), matches.group("domain"))

View File

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

6
tests/conftest.py Normal file
View File

@ -0,0 +1,6 @@
import pytest
@pytest.fixture(autouse=True)
def enable_db_access_for_all_tests(db):
pass

View File

@ -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": []}

View File

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