Rework to a domains model for better vhosting

This commit is contained in:
Andrew Godwin 2022-11-06 13:48:04 -07:00
parent 8aec395331
commit dbe57075d3
24 changed files with 518 additions and 169 deletions

View file

@ -2,4 +2,6 @@ from django.conf import settings
def config_context(request): def config_context(request):
return {"config": {"site_name": settings.SITE_NAME}} return {
"config": {"site_name": settings.SITE_NAME},
}

View file

@ -252,7 +252,7 @@ def builtin_document_loader(url: str, options={}):
) )
def canonicalise(json_data): def canonicalise(json_data, include_security=False):
""" """
Given an ActivityPub JSON-LD document, round-trips it through the LD Given an ActivityPub JSON-LD document, round-trips it through the LD
systems to end up in a canonicalised, compacted format. systems to end up in a canonicalised, compacted format.
@ -264,5 +264,12 @@ def canonicalise(json_data):
raise ValueError("Pass decoded JSON data into LDDocument") raise ValueError("Pass decoded JSON data into LDDocument")
return jsonld.compact( return jsonld.compact(
jsonld.expand(json_data), jsonld.expand(json_data),
["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], (
[
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
]
if include_security
else "https://www.w3.org/ns/activitystreams"
),
) )

48
core/signatures.py Normal file
View file

@ -0,0 +1,48 @@
import base64
from typing import Any, Dict, List
from cryptography.hazmat.primitives import hashes
from django.http import HttpRequest
class HttpSignature:
"""
Allows for calculation and verification of HTTP signatures
"""
@classmethod
def calculate_digest(cls, data, algorithm="sha-256") -> str:
"""
Calculates the digest header value for a given HTTP body
"""
if algorithm == "sha-256":
digest = hashes.Hash(hashes.SHA256())
digest.update(data)
return "SHA-256=" + base64.b64encode(digest.finalize()).decode("ascii")
else:
raise ValueError(f"Unknown digest algorithm {algorithm}")
@classmethod
def headers_from_request(cls, request: HttpRequest, header_names: List[str]) -> str:
"""
Creates the to-be-signed header payload from a Django request"""
headers = {}
for header_name in header_names:
if header_name == "(request-target)":
value = f"post {request.path}"
elif header_name == "content-type":
value = request.META["CONTENT_TYPE"]
else:
value = request.META[f"HTTP_{header_name.upper()}"]
headers[header_name] = value
return "\n".join(f"{name.lower()}: {value}" for name, value in headers.items())
@classmethod
def parse_signature(cls, signature) -> Dict[str, Any]:
signature_details = {}
for item in signature.split(","):
name, value = item.split("=", 1)
value = value.strip('"')
signature_details[name.lower()] = value
signature_details["headers"] = signature_details["headers"].split()
return signature_details

View file

@ -1,4 +1,4 @@
# Generated by Django 4.1.3 on 2022-11-06 03:59 # Generated by Django 4.1.3 on 2022-11-06 19:58
from django.db import migrations, models from django.db import migrations, models
@ -22,7 +22,12 @@ class Migration(migrations.Migration):
verbose_name="ID", verbose_name="ID",
), ),
), ),
("type", models.CharField(max_length=500)), (
"type",
models.CharField(
choices=[("identity_fetch", "Identity Fetch")], max_length=500
),
),
("priority", models.IntegerField(default=0)), ("priority", models.IntegerField(default=0)),
("subject", models.TextField()), ("subject", models.TextField()),
("payload", models.JSONField(blank=True, null=True)), ("payload", models.JSONField(blank=True, null=True)),

View file

@ -64,5 +64,8 @@ class QueueProcessor(View):
await task.fail(f"{e}\n\n" + traceback.format_exc()) await task.fail(f"{e}\n\n" + traceback.format_exc())
async def handle_identity_fetch(self, subject, payload): async def handle_identity_fetch(self, subject, payload):
identity = await sync_to_async(Identity.by_handle)(subject) # Get the actor URI via webfinger
await identity.fetch_details() actor_uri, handle = await Identity.fetch_webfinger(subject)
# Get or create the identity, then fetch
identity = await sync_to_async(Identity.by_actor_uri)(actor_uri, create=True)
await identity.fetch_actor()

View file

@ -229,7 +229,8 @@ form .help-block {
padding: 4px 0 0 0; padding: 4px 0 0 0;
} }
form input { form input,
form select {
width: 100%; width: 100%;
padding: 4px 6px; padding: 4px 6px;
background: var(--color-bg1); background: var(--color-bg1);

View file

@ -1,4 +1,4 @@
# Generated by Django 4.1.3 on 2022-11-05 23:50 # Generated by Django 4.1.3 on 2022-11-06 19:58
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View file

@ -36,4 +36,4 @@ class Status(models.Model):
) )
class urls(urlman.Urls): class urls(urlman.Urls):
view = "{self.identity.urls.view}{self.id}/" view = "{self.identity.urls.view}statuses/{self.id}/"

View file

@ -10,7 +10,7 @@ SECRET_KEY = "insecure_secret"
DEBUG = True DEBUG = True
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"]
# Application definition # Application definition
@ -36,6 +36,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"users.middleware.IdentityMiddleware",
] ]
ROOT_URLCONF = "takahe.urls" ROOT_URLCONF = "takahe.urls"

View file

@ -26,10 +26,12 @@
<li> <li>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="/identity/select/"> <a href="/identity/select/">
{% if user.icon_uri %} {% if not request.identity %}
<img src="{{ user.icon_uri }}" width="32"> <img src="{% static "img/unknown-icon-128.png" %}" width="32" title="No identity selected">
{% elif request.identity.icon_uri %}
<img src="{{ request.identity.icon_uri }}" width="32" title="{{ request.identity.handle }}">
{% else %} {% else %}
<img src="{% static "img/unknown-icon-128.png" %}" width="32"> <img src="{% static "img/unknown-icon-128.png" %}" width="32" title="{{ request.identity.handle }}">
{% endif %} {% endif %}
</a> </a>
{% else %} {% else %}

View file

@ -14,7 +14,7 @@
<img src="{% static "img/unknown-icon-128.png" %}" width="32"> <img src="{% static "img/unknown-icon-128.png" %}" width="32">
{% endif %} {% endif %}
{{ identity }} {{ identity }}
<small>@{{ identity.short_handle }}</small> <small>@{{ identity.handle }}</small>
</a> </a>
{% empty %} {% empty %}
<p class="option empty">You have no identities.</p> <p class="option empty">You have no identities.</p>

View file

@ -10,7 +10,7 @@
{% else %} {% else %}
<img src="{% static "img/unknown-icon-128.png" %}" class="icon"> <img src="{% static "img/unknown-icon-128.png" %}" class="icon">
{% endif %} {% endif %}
{{ identity }} <small>{{ identity.handle }}</small> {{ identity }} <small>@{{ identity.handle }}</small>
</h1> </h1>
{% if not identity.local %} {% if not identity.local %}

View file

@ -2,7 +2,7 @@
<h3 class="author"> <h3 class="author">
<a href="{{ status.identity.urls.view }}"> <a href="{{ status.identity.urls.view }}">
{{ status.identity }} {{ status.identity }}
<small>{{ status.identity.short_handle }}</small> <small>{{ status.identity.handle }}</small>
</a> </a>
</h3> </h3>
<time> <time>

View file

@ -1,6 +1,11 @@
from django.contrib import admin from django.contrib import admin
from users.models import Identity, User, UserEvent from users.models import Domain, Identity, User, UserEvent
@admin.register(Domain)
class DomainAdmin(admin.ModelAdmin):
list_display = ["domain", "service_domain", "local", "blocked", "public"]
@admin.register(User) @admin.register(User)

View file

@ -3,8 +3,6 @@ from functools import wraps
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from users.models import Identity
def identity_required(function): def identity_required(function):
""" """
@ -16,24 +14,15 @@ def identity_required(function):
# They do have to be logged in # They do have to be logged in
if not request.user.is_authenticated: if not request.user.is_authenticated:
return redirect_to_login(next=request.get_full_path()) return redirect_to_login(next=request.get_full_path())
# Try to retrieve their active identity
identity_id = request.session.get("identity_id")
if not identity_id:
identity = None
else:
try:
identity = Identity.objects.get(id=identity_id)
except Identity.DoesNotExist:
identity = None
# If there's no active one, try to auto-select one # If there's no active one, try to auto-select one
if identity is None: if request.identity is None:
possible_identities = list(request.user.identities.all()) possible_identities = list(request.user.identities.all())
if len(possible_identities) != 1: if len(possible_identities) != 1:
# OK, send them to the identity selection page to select/create one # OK, send them to the identity selection page to select/create one
return HttpResponseRedirect("/identity/select/") return HttpResponseRedirect("/identity/select/")
identity = possible_identities[0] identity = possible_identities[0]
request.identity = identity request.session["identity_id"] = identity.pk
request.session["identity_id"] = identity.pk request.identity = identity
return function(request, *args, **kwargs) return function(request, *args, **kwargs)
return inner return inner

24
users/middleware.py Normal file
View file

@ -0,0 +1,24 @@
from users.models import Identity
class IdentityMiddleware:
"""
Adds a request.identity object which is either the current session's
identity, or None if they have not picked one yet/it's invalid.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
identity_id = request.session.get("identity_id")
if not identity_id:
request.identity = None
else:
try:
request.identity = Identity.objects.get(id=identity_id)
except Identity.DoesNotExist:
request.identity = None
response = self.get_response(request)
return response

View file

@ -1,4 +1,4 @@
# Generated by Django 4.1.3 on 2022-11-05 23:50 # Generated by Django 4.1.3 on 2022-11-06 19:58
import functools import functools
@ -47,6 +47,30 @@ class Migration(migrations.Migration):
"abstract": False, "abstract": False,
}, },
), ),
migrations.CreateModel(
name="Domain",
fields=[
(
"domain",
models.CharField(max_length=250, primary_key=True, serialize=False),
),
(
"service_domain",
models.CharField(blank=True, max_length=250, null=True),
),
("local", models.BooleanField()),
("blocked", models.BooleanField(default=False)),
("public", models.BooleanField()),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"users",
models.ManyToManyField(
blank=True, related_name="domains", to=settings.AUTH_USER_MODEL
),
),
],
),
migrations.CreateModel( migrations.CreateModel(
name="UserEvent", name="UserEvent",
fields=[ fields=[
@ -94,10 +118,20 @@ class Migration(migrations.Migration):
verbose_name="ID", verbose_name="ID",
), ),
), ),
("handle", models.CharField(max_length=500, unique=True)), (
"actor_uri",
models.CharField(
blank=True, max_length=500, null=True, unique=True
),
),
("local", models.BooleanField()),
("username", models.CharField(blank=True, max_length=500, null=True)),
("name", models.CharField(blank=True, max_length=500, null=True)), ("name", models.CharField(blank=True, max_length=500, null=True)),
("summary", models.TextField(blank=True, null=True)), ("summary", models.TextField(blank=True, null=True)),
("actor_uri", models.CharField(blank=True, max_length=500, null=True)), (
"manually_approves_followers",
models.BooleanField(blank=True, null=True),
),
( (
"profile_uri", "profile_uri",
models.CharField(blank=True, max_length=500, null=True), models.CharField(blank=True, max_length=500, null=True),
@ -130,17 +164,21 @@ class Migration(migrations.Migration):
), ),
), ),
), ),
("local", models.BooleanField()),
(
"manually_approves_followers",
models.BooleanField(blank=True, null=True),
),
("private_key", models.TextField(blank=True, null=True)), ("private_key", models.TextField(blank=True, null=True)),
("public_key", models.TextField(blank=True, null=True)), ("public_key", models.TextField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)), ("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)), ("updated", models.DateTimeField(auto_now=True)),
("fetched", models.DateTimeField(blank=True, null=True)), ("fetched", models.DateTimeField(blank=True, null=True)),
("deleted", models.DateTimeField(blank=True, null=True)), ("deleted", models.DateTimeField(blank=True, null=True)),
(
"domain",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="users.domain",
),
),
( (
"users", "users",
models.ManyToManyField( models.ManyToManyField(
@ -148,6 +186,10 @@ class Migration(migrations.Migration):
), ),
), ),
], ],
options={
"verbose_name_plural": "identities",
"unique_together": {("username", "domain")},
},
), ),
migrations.CreateModel( migrations.CreateModel(
name="Follow", name="Follow",
@ -182,4 +224,39 @@ class Migration(migrations.Migration):
), ),
], ],
), ),
migrations.CreateModel(
name="Block",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("mute", models.BooleanField()),
("expires", models.DateTimeField(blank=True, null=True)),
("note", models.TextField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"source",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="outbound_blocks",
to="users.identity",
),
),
(
"target",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="inbound_blocks",
to="users.identity",
),
),
],
),
] ]

View file

@ -1,3 +1,5 @@
from .block import Block # noqa
from .domain import Domain # noqa
from .follow import Follow # noqa from .follow import Follow # noqa
from .identity import Identity # noqa from .identity import Identity # noqa
from .user import User # noqa from .user import User # noqa

30
users/models/block.py Normal file
View file

@ -0,0 +1,30 @@
from django.db import models
class Block(models.Model):
"""
When one user (the source) mutes or blocks another (the target)
"""
source = models.ForeignKey(
"users.Identity",
on_delete=models.CASCADE,
related_name="outbound_blocks",
)
target = models.ForeignKey(
"users.Identity",
on_delete=models.CASCADE,
related_name="inbound_blocks",
)
# If it is a mute, we will stop delivering any activities from target to
# source, but we will still deliver activities from source to target.
# A full block (non-mute) stops activities both ways.
mute = models.BooleanField()
expires = models.DateTimeField(blank=True, null=True)
note = models.TextField(blank=True, null=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)

83
users/models/domain.py Normal file
View file

@ -0,0 +1,83 @@
from typing import Optional
from django.db import models
class Domain(models.Model):
"""
Represents a domain that a user can have an account on.
For protocol reasons, if we want to allow custom usernames
per domain, each "display" domain (the one in the handle) must either let
us serve on it directly, or have a "service" domain that maps
to it uniquely that we can serve on that.
That way, someone coming in with just an Actor URI as their
entrypoint can still try to webfinger preferredUsername@actorDomain
and we can return an appropriate response.
It's possible to just have one domain do both jobs, of course.
This model also represents _other_ servers' domains, which we treat as
display domains for now, until we start doing better probing.
"""
domain = models.CharField(max_length=250, primary_key=True)
service_domain = models.CharField(
max_length=250,
null=True,
blank=True,
db_index=True,
unique=True,
)
# If we own this domain
local = models.BooleanField()
# If we have blocked this domain from interacting with us
blocked = models.BooleanField(default=False)
# Domains can be joinable by any user of the instance (as the default one
# should)
public = models.BooleanField(default=False)
# Domains can also be linked to one or more users for their private use
# This should be display domains ONLY
users = models.ManyToManyField("users.User", related_name="domains", blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@classmethod
def get_remote_domain(cls, domain) -> "Domain":
try:
return cls.objects.get(domain=domain, local=False)
except cls.DoesNotExist:
return cls.objects.create(domain=domain, local=False)
@classmethod
def get_local_domain(cls, domain) -> Optional["Domain"]:
try:
return cls.objects.get(
models.Q(domain=domain) | models.Q(service_domain=domain)
)
except cls.DoesNotExist:
return None
@property
def uri_domain(self) -> str:
if self.service_domain:
return self.service_domain
return self.domain
@classmethod
def available_for_user(cls, user):
"""
Returns domains that are available for the user to put an identity on
"""
return cls.objects.filter(
models.Q(public=True) | models.Q(users__id=user.id),
local=True,
)
def __str__(self):
return self.domain

View file

@ -3,7 +3,7 @@ from django.db import models
class Follow(models.Model): class Follow(models.Model):
""" """
Tracks major events that happen to users When one user (the source) follows other (the target)
""" """
source = models.ForeignKey( source = models.ForeignKey(

View file

@ -1,6 +1,8 @@
import base64 import base64
import uuid import uuid
from functools import partial from functools import partial
from typing import Optional, Tuple
from urllib.parse import urlparse
import httpx import httpx
import urlman import urlman
@ -14,6 +16,7 @@ from django.utils import timezone
from django.utils.http import http_date from django.utils.http import http_date
from core.ld import canonicalise from core.ld import canonicalise
from users.models.domain import Domain
def upload_namer(prefix, instance, filename): def upload_namer(prefix, instance, filename):
@ -30,12 +33,26 @@ class Identity(models.Model):
Represents both local and remote Fediverse identities (actors) Represents both local and remote Fediverse identities (actors)
""" """
# The handle includes the domain! # The Actor URI is essentially also a PK - we keep the default numeric
handle = models.CharField(max_length=500, unique=True) # one around as well for making nice URLs etc.
actor_uri = models.CharField(max_length=500, blank=True, null=True, unique=True)
local = models.BooleanField()
users = models.ManyToManyField("users.User", related_name="identities")
username = models.CharField(max_length=500, blank=True, null=True)
# Must be a display domain if present
domain = models.ForeignKey(
"users.Domain",
blank=True,
null=True,
on_delete=models.PROTECT,
)
name = models.CharField(max_length=500, blank=True, null=True) name = models.CharField(max_length=500, blank=True, null=True)
summary = models.TextField(blank=True, null=True) summary = models.TextField(blank=True, null=True)
manually_approves_followers = models.BooleanField(blank=True, null=True)
actor_uri = models.CharField(max_length=500, blank=True, null=True, db_index=True)
profile_uri = models.CharField(max_length=500, blank=True, null=True) profile_uri = models.CharField(max_length=500, blank=True, null=True)
inbox_uri = models.CharField(max_length=500, blank=True, null=True) inbox_uri = models.CharField(max_length=500, blank=True, null=True)
outbox_uri = models.CharField(max_length=500, blank=True, null=True) outbox_uri = models.CharField(max_length=500, blank=True, null=True)
@ -49,9 +66,6 @@ class Identity(models.Model):
upload_to=partial(upload_namer, "background_images"), blank=True, null=True upload_to=partial(upload_namer, "background_images"), blank=True, null=True
) )
local = models.BooleanField()
users = models.ManyToManyField("users.User", related_name="identities")
manually_approves_followers = models.BooleanField(blank=True, null=True)
private_key = models.TextField(null=True, blank=True) private_key = models.TextField(null=True, blank=True)
public_key = models.TextField(null=True, blank=True) public_key = models.TextField(null=True, blank=True)
@ -62,36 +76,37 @@ class Identity(models.Model):
class Meta: class Meta:
verbose_name_plural = "identities" verbose_name_plural = "identities"
unique_together = [("username", "domain")]
@classmethod @classmethod
def by_handle(cls, handle, create=True): def by_handle(cls, handle, fetch=False, local=False):
if handle.startswith("@"): if handle.startswith("@"):
raise ValueError("Handle must not start with @") raise ValueError("Handle must not start with @")
if "@" not in handle: if "@" not in handle:
raise ValueError("Handle must contain domain") raise ValueError("Handle must contain domain")
username, domain = handle.split("@")
try: try:
return cls.objects.filter(handle=handle).get() if local:
return cls.objects.get(username=username, domain_id=domain, local=True)
else:
return cls.objects.get(username=username, domain_id=domain)
except cls.DoesNotExist: except cls.DoesNotExist:
if create: if fetch and not local:
return cls.objects.create(handle=handle, local=False) return cls.objects.create(handle=handle, local=False)
return None return None
@classmethod @classmethod
def by_actor_uri(cls, uri): def by_actor_uri(cls, uri, create=False):
try: try:
cls.objects.filter(actor_uri=uri) return cls.objects.get(actor_uri=uri)
except cls.DoesNotExist: except cls.DoesNotExist:
if create:
return cls.objects.create(actor_uri=uri, local=False)
return None return None
@property @property
def short_handle(self): def handle(self):
if self.handle.endswith("@" + settings.DEFAULT_DOMAIN): return f"{self.username}@{self.domain_id}"
return self.handle.split("@", 1)[0]
return self.handle
@property
def domain(self):
return self.handle.split("@", 1)[1]
@property @property
def data_age(self) -> float: def data_age(self) -> float:
@ -105,6 +120,8 @@ class Identity(models.Model):
return (timezone.now() - self.fetched).total_seconds() return (timezone.now() - self.fetched).total_seconds()
def generate_keypair(self): def generate_keypair(self):
if not self.local:
raise ValueError("Cannot generate keypair for remote user")
private_key = rsa.generate_private_key( private_key = rsa.generate_private_key(
public_exponent=65537, public_exponent=65537,
key_size=2048, key_size=2048,
@ -120,44 +137,39 @@ class Identity(models.Model):
) )
self.save() self.save()
async def fetch_details(self): @classmethod
if self.local: async def fetch_webfinger(cls, handle: str) -> Tuple[Optional[str], Optional[str]]:
raise ValueError("Cannot fetch local identities") """
self.actor_uri = None Given a username@domain handle, returns a tuple of
self.inbox_uri = None (actor uri, canonical handle) or None, None if it does not resolve.
self.profile_uri = None """
# Go knock on webfinger and see what their address is domain = handle.split("@")[1]
await self.fetch_webfinger()
# Fetch actor JSON
if self.actor_uri:
await self.fetch_actor()
self.fetched = timezone.now()
await sync_to_async(self.save)()
async def fetch_webfinger(self) -> bool:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get( response = await client.get(
f"https://{self.domain}/.well-known/webfinger?resource=acct:{self.handle}", f"https://{domain}/.well-known/webfinger?resource=acct:{handle}",
headers={"Accept": "application/json"}, headers={"Accept": "application/json"},
follow_redirects=True, follow_redirects=True,
) )
if response.status_code >= 400: if response.status_code >= 400:
return False return None, None
data = response.json() data = response.json()
if data["subject"].startswith("acct:"):
data["subject"] = data["subject"][5:]
for link in data["links"]: for link in data["links"]:
if ( if (
link.get("type") == "application/activity+json" link.get("type") == "application/activity+json"
and link.get("rel") == "self" and link.get("rel") == "self"
): ):
self.actor_uri = link["href"] return link["href"], data["subject"]
elif ( return None, None
link.get("type") == "text/html"
and link.get("rel") == "http://webfinger.net/rel/profile-page"
):
self.profile_uri = link["href"]
return True
async def fetch_actor(self) -> bool: async def fetch_actor(self) -> bool:
"""
Fetches the user's actor information, as well as their domain from
webfinger if it's available.
"""
if self.local:
raise ValueError("Cannot fetch local identities")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get( response = await client.get(
self.actor_uri, self.actor_uri,
@ -166,29 +178,48 @@ class Identity(models.Model):
) )
if response.status_code >= 400: if response.status_code >= 400:
return False return False
document = canonicalise(response.json()) document = canonicalise(response.json(), include_security=True)
self.name = document.get("name") self.name = document.get("name")
self.profile_uri = document.get("url")
self.inbox_uri = document.get("inbox") self.inbox_uri = document.get("inbox")
self.outbox_uri = document.get("outbox") self.outbox_uri = document.get("outbox")
self.summary = document.get("summary") self.summary = document.get("summary")
self.username = document.get("preferredUsername")
self.manually_approves_followers = document.get( self.manually_approves_followers = document.get(
"as:manuallyApprovesFollowers" "as:manuallyApprovesFollowers"
) )
self.public_key = document.get("publicKey", {}).get("publicKeyPem") self.public_key = document.get("publicKey", {}).get("publicKeyPem")
self.icon_uri = document.get("icon", {}).get("url") self.icon_uri = document.get("icon", {}).get("url")
self.image_uri = document.get("image", {}).get("url") self.image_uri = document.get("image", {}).get("url")
# Now go do webfinger with that info to see if we can get a canonical domain
actor_url_parts = urlparse(self.actor_uri)
get_domain = sync_to_async(Domain.get_remote_domain)
if self.username:
webfinger_actor, webfinger_handle = await self.fetch_webfinger(
f"{self.username}@{actor_url_parts.hostname}"
)
if webfinger_handle:
webfinger_username, webfinger_domain = webfinger_handle.split("@")
self.username = webfinger_username
self.domain = await get_domain(webfinger_domain)
else:
self.domain = await get_domain(actor_url_parts.hostname)
else:
self.domain = await get_domain(actor_url_parts.hostname)
self.fetched = timezone.now()
await sync_to_async(self.save)()
return True return True
def sign(self, cleartext: str) -> str: def sign(self, cleartext: str) -> str:
if not self.private_key: if not self.private_key:
raise ValueError("Cannot sign - no private key") raise ValueError("Cannot sign - no private key")
private_key = serialization.load_pem_private_key( private_key = serialization.load_pem_private_key(
self.private_key, self.private_key.encode("ascii"),
password=None, password=None,
) )
return base64.b64encode( return base64.b64encode(
private_key.sign( private_key.sign(
cleartext, cleartext.encode("utf8"),
padding.PSS( padding.PSS(
mgf=padding.MGF1(hashes.SHA256()), mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH, salt_length=padding.PSS.MAX_LENGTH,
@ -199,12 +230,13 @@ class Identity(models.Model):
def verify_signature(self, crypttext: str, cleartext: str) -> bool: def verify_signature(self, crypttext: str, cleartext: str) -> bool:
if not self.public_key: if not self.public_key:
raise ValueError("Cannot verify - no private key") raise ValueError("Cannot verify - no public key")
public_key = serialization.load_pem_public_key(self.public_key) public_key = serialization.load_pem_public_key(self.public_key.encode("ascii"))
print("sig??", crypttext, cleartext)
try: try:
public_key.verify( public_key.verify(
crypttext, crypttext.encode("utf8"),
cleartext, cleartext.encode("utf8"),
padding.PSS( padding.PSS(
mgf=padding.MGF1(hashes.SHA256()), mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH, salt_length=padding.PSS.MAX_LENGTH,
@ -250,10 +282,18 @@ class Identity(models.Model):
pass pass
def __str__(self): def __str__(self):
return self.name or self.handle return self.handle or self.actor_uri
class urls(urlman.Urls): class urls(urlman.Urls):
view = "/@{self.short_handle}/" view = "/@{self.username}@{self.domain_id}/"
view_short = "/@{self.username}/"
actor = "{view}actor/" actor = "{view}actor/"
inbox = "{actor}inbox/" inbox = "{actor}inbox/"
outbox = "{actor}outbox/"
activate = "{view}activate/" activate = "{view}activate/"
def get_scheme(self, url):
return "https"
def get_hostname(self, url):
return self.instance.domain.uri_domain

View file

@ -1,7 +1,7 @@
from django.conf import settings from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from users.models import Identity from users.models import Domain, Identity
def by_handle_or_404(request, handle, local=True): def by_handle_or_404(request, handle, local=True):
@ -9,10 +9,25 @@ def by_handle_or_404(request, handle, local=True):
Retrieves an Identity by its long or short handle. Retrieves an Identity by its long or short handle.
Domain-sensitive, so it will understand short handles on alternate domains. Domain-sensitive, so it will understand short handles on alternate domains.
""" """
# TODO: Domain sensitivity
if "@" not in handle: if "@" not in handle:
handle += "@" + settings.DEFAULT_DOMAIN if "HTTP_HOST" not in request.META:
if local: raise Http404("No hostname available")
return get_object_or_404(Identity.objects.filter(local=True), handle=handle) username = handle
domain_instance = Domain.get_local_domain(request.META["HTTP_HOST"])
if domain_instance is None:
raise Http404("No matching domains found")
domain = domain_instance.domain
else: else:
return get_object_or_404(Identity, handle=handle) username, domain = handle.split("@", 1)
if local:
return get_object_or_404(
Identity.objects.filter(local=True),
username=username,
domain_id=domain,
)
else:
return get_object_or_404(
Identity,
username=username,
domain_id=domain,
)

View file

@ -1,8 +1,7 @@
import base64
import json import json
import string import string
from cryptography.hazmat.primitives import hashes from asgiref.sync import async_to_sync
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@ -14,8 +13,9 @@ from django.views.generic import FormView, TemplateView, View
from core.forms import FormHelper from core.forms import FormHelper
from core.ld import canonicalise from core.ld import canonicalise
from core.signatures import HttpSignature
from miniq.models import Task from miniq.models import Task
from users.models import Identity from users.models import Domain, Identity
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -24,7 +24,7 @@ class ViewIdentity(TemplateView):
template_name = "identity/view.html" template_name = "identity/view.html"
def get_context_data(self, handle): def get_context_data(self, handle):
identity = Identity.by_handle(handle=handle) identity = by_handle_or_404(self.request, handle, local=False)
statuses = identity.statuses.all()[:100] statuses = identity.statuses.all()[:100]
if identity.data_age > settings.IDENTITY_MAX_AGE: if identity.data_age > settings.IDENTITY_MAX_AGE:
Task.submit("identity_fetch", identity.handle) Task.submit("identity_fetch", identity.handle)
@ -65,36 +65,49 @@ class CreateIdentity(FormView):
template_name = "identity/create.html" template_name = "identity/create.html"
class form_class(forms.Form): class form_class(forms.Form):
handle = forms.CharField() username = forms.CharField()
name = forms.CharField() name = forms.CharField()
helper = FormHelper(submit_text="Create") helper = FormHelper(submit_text="Create")
def clean_handle(self): def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["domain"] = forms.ChoiceField(
choices=[
(domain.domain, domain.domain)
for domain in Domain.available_for_user(user)
]
)
def clean_username(self):
# Remove any leading @ # Remove any leading @
value = self.cleaned_data["handle"].lstrip("@") value = self.cleaned_data["username"].lstrip("@")
# Validate it's all ascii characters # Validate it's all ascii characters
for character in value: for character in value:
if character not in string.ascii_letters + string.digits + "_-": if character not in string.ascii_letters + string.digits + "_-":
raise forms.ValidationError( raise forms.ValidationError(
"Only the letters a-z, numbers 0-9, dashes and underscores are allowed." "Only the letters a-z, numbers 0-9, dashes and underscores are allowed."
) )
# Don't allow custom domains here quite yet
if "@" in value:
raise forms.ValidationError(
"You are not allowed an @ sign in your handle."
)
# Ensure there is a domain on the end
if "@" not in value:
value += "@" + settings.DEFAULT_DOMAIN
# Check for existing users
if Identity.objects.filter(handle=value).exists():
raise forms.ValidationError("This handle is already taken")
return value return value
def clean(self):
# Check for existing users
username = self.cleaned_data["username"]
domain = self.cleaned_data["domain"]
if Identity.objects.filter(username=username, domain=domain).exists():
raise forms.ValidationError(f"{username}@{domain} is already taken")
def get_form(self):
form_class = self.get_form_class()
return form_class(user=self.request.user, **self.get_form_kwargs())
def form_valid(self, form): def form_valid(self, form):
username = form.cleaned_data["username"]
domain = form.cleaned_data["domain"]
new_identity = Identity.objects.create( new_identity = Identity.objects.create(
handle=form.cleaned_data["handle"], actor_uri=f"https://{domain}/@{username}/actor/",
username=username,
domain_id=domain,
name=form.cleaned_data["name"], name=form.cleaned_data["name"],
local=True, local=True,
) )
@ -110,23 +123,28 @@ class Actor(View):
def get(self, request, handle): def get(self, request, handle):
identity = by_handle_or_404(self.request, handle) identity = by_handle_or_404(self.request, handle)
return JsonResponse( response = {
{ "@context": [
"@context": [ "https://www.w3.org/ns/activitystreams",
"https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1",
"https://w3id.org/security/v1", ],
], "id": identity.urls.actor.full(),
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}", "type": "Person",
"type": "Person", "inbox": identity.urls.inbox.full(),
"preferredUsername": identity.short_handle, "preferredUsername": identity.username,
"inbox": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.inbox}", "publicKey": {
"publicKey": { "id": identity.urls.actor.full() + "#main-key",
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}#main-key", "owner": identity.urls.actor.full(),
"owner": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}", "publicKeyPem": identity.public_key,
"publicKeyPem": identity.public_key, },
}, "published": identity.created.strftime("%Y-%m-%dT%H:%M:%SZ"),
} "url": identity.urls.view_short.full(),
) }
if identity.name:
response["name"] = identity.name
if identity.summary:
response["summary"] = identity.summary
return JsonResponse(canonicalise(response, include_security=True))
@method_decorator(csrf_exempt, name="dispatch") @method_decorator(csrf_exempt, name="dispatch")
@ -136,48 +154,45 @@ class Inbox(View):
""" """
def post(self, request, handle): def post(self, request, handle):
# Verify body digest
if "HTTP_DIGEST" in request.META:
expected_digest = HttpSignature.calculate_digest(request.body)
if request.META["HTTP_DIGEST"] != expected_digest:
print("Bad digest")
return HttpResponseBadRequest()
# Get the signature details
if "HTTP_SIGNATURE" not in request.META: if "HTTP_SIGNATURE" not in request.META:
print("No signature") print("No signature")
return HttpResponseBadRequest() return HttpResponseBadRequest()
# Split apart signature signature_details = HttpSignature.parse_signature(
signature_details = {} request.META["HTTP_SIGNATURE"]
for item in request.META["HTTP_SIGNATURE"].split(","): )
name, value = item.split("=", 1)
value = value.strip('"')
signature_details[name] = value
# Reject unknown algorithms # Reject unknown algorithms
if signature_details["algorithm"] != "rsa-sha256": if signature_details["algorithm"] != "rsa-sha256":
print("Unknown algorithm") print("Unknown algorithm")
return HttpResponseBadRequest() return HttpResponseBadRequest()
# Calculate body digest
if "HTTP_DIGEST" in request.META:
digest = hashes.Hash(hashes.SHA256())
digest.update(request.body)
digest_header = "SHA-256=" + base64.b64encode(digest.finalize()).decode(
"ascii"
)
if request.META["HTTP_DIGEST"] != digest_header:
print("Bad digest")
return HttpResponseBadRequest()
# Create the signature payload # Create the signature payload
headers = {} headers_string = HttpSignature.headers_from_request(
for header_name in signature_details["headers"].split(): request, signature_details["headers"]
if header_name == "(request-target)": )
value = f"post {request.path}"
elif header_name == "content-type":
value = request.META["CONTENT_TYPE"]
else:
value = request.META[f"HTTP_{header_name.upper()}"]
headers[header_name] = value
signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items())
# Load the LD # Load the LD
document = canonicalise(json.loads(request.body)) document = canonicalise(json.loads(request.body))
print(signature_details)
print(headers_string)
print(document) print(document)
# Find the Identity by the actor on the incoming item # Find the Identity by the actor on the incoming item
identity = Identity.by_actor_uri(document["actor"]) identity = Identity.by_actor_uri(document["actor"], create=True)
if not identity.verify_signature(signature_details["signature"], signed_string): if not identity.public_key:
# See if we can fetch it right now
async_to_sync(identity.fetch_actor)()
if not identity.public_key:
print("Cannot retrieve actor")
return HttpResponseBadRequest("Cannot retrieve actor")
if not identity.verify_signature(
signature_details["signature"], headers_string
):
print("Bad signature") print("Bad signature")
return HttpResponseBadRequest() # return HttpResponseBadRequest("Bad signature")
return JsonResponse({"status": "OK"}) return JsonResponse({"status": "OK"})
@ -190,24 +205,24 @@ class Webfinger(View):
resource = request.GET.get("resource") resource = request.GET.get("resource")
if not resource.startswith("acct:"): if not resource.startswith("acct:"):
raise Http404("Not an account resource") raise Http404("Not an account resource")
handle = resource[5:] handle = resource[5:].replace("testfedi", "feditest")
identity = by_handle_or_404(request, handle) identity = by_handle_or_404(request, handle)
return JsonResponse( return JsonResponse(
{ {
"subject": f"acct:{identity.handle}", "subject": f"acct:{identity.handle}",
"aliases": [ "aliases": [
f"https://{settings.DEFAULT_DOMAIN}/@{identity.short_handle}", identity.urls.view_short.full(),
], ],
"links": [ "links": [
{ {
"rel": "http://webfinger.net/rel/profile-page", "rel": "http://webfinger.net/rel/profile-page",
"type": "text/html", "type": "text/html",
"href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.view}", "href": identity.urls.view_short.full(),
}, },
{ {
"rel": "self", "rel": "self",
"type": "application/activity+json", "type": "application/activity+json",
"href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}", "href": identity.urls.actor.full(),
}, },
], ],
} }