Add basic webfinger implementation for local users
parent
422f6556a2
commit
174fcfedbc
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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),
|
||||
]
|
||||
|
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
from printpub.user.serializers.webfinger_user import WebfingerUserSerializer
|
|
@ -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 []
|
|
@ -0,0 +1,4 @@
|
|||
from django.urls import path
|
||||
from printpub.user import views
|
||||
|
||||
urlpatterns = [path(".well-known/webfinger", views.WebFinger.as_view())]
|
|
@ -1,3 +0,0 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
|
@ -0,0 +1 @@
|
|||
from printpub.user.views.webfinger import WebFinger
|
|
@ -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"))
|
|
@ -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"
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_db_access_for_all_tests(db):
|
||||
pass
|
|
@ -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": []}
|
|
@ -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
|
Loading…
Reference in New Issue