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):
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
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")
return jsonld.compact(
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
@ -22,7 +22,12 @@ class Migration(migrations.Migration):
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)),
("subject", models.TextField()),
("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())
async def handle_identity_fetch(self, subject, payload):
identity = await sync_to_async(Identity.by_handle)(subject)
await identity.fetch_details()
# Get the actor URI via webfinger
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;
}
form input {
form input,
form select {
width: 100%;
padding: 4px 6px;
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
from django.db import migrations, models

View File

@ -36,4 +36,4 @@ class Status(models.Model):
)
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
ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"]
# Application definition
@ -36,6 +36,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"users.middleware.IdentityMiddleware",
]
ROOT_URLCONF = "takahe.urls"

View File

@ -26,10 +26,12 @@
<li>
{% if user.is_authenticated %}
<a href="/identity/select/">
{% if user.icon_uri %}
<img src="{{ user.icon_uri }}" width="32">
{% if not request.identity %}
<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 %}
<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 %}
</a>
{% else %}

View File

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

View File

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

View File

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

View File

@ -1,6 +1,11 @@
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)

View File

@ -3,8 +3,6 @@ from functools import wraps
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponseRedirect
from users.models import Identity
def identity_required(function):
"""
@ -16,24 +14,15 @@ def identity_required(function):
# They do have to be logged in
if not request.user.is_authenticated:
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 identity is None:
if request.identity is None:
possible_identities = list(request.user.identities.all())
if len(possible_identities) != 1:
# OK, send them to the identity selection page to select/create one
return HttpResponseRedirect("/identity/select/")
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 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
@ -47,6 +47,30 @@ class Migration(migrations.Migration):
"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(
name="UserEvent",
fields=[
@ -94,10 +118,20 @@ class Migration(migrations.Migration):
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)),
("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",
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)),
("public_key", models.TextField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
("fetched", 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",
models.ManyToManyField(
@ -148,6 +186,10 @@ class Migration(migrations.Migration):
),
),
],
options={
"verbose_name_plural": "identities",
"unique_together": {("username", "domain")},
},
),
migrations.CreateModel(
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 .identity import Identity # 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):
"""
Tracks major events that happen to users
When one user (the source) follows other (the target)
"""
source = models.ForeignKey(

View File

@ -1,6 +1,8 @@
import base64
import uuid
from functools import partial
from typing import Optional, Tuple
from urllib.parse import urlparse
import httpx
import urlman
@ -14,6 +16,7 @@ from django.utils import timezone
from django.utils.http import http_date
from core.ld import canonicalise
from users.models.domain import Domain
def upload_namer(prefix, instance, filename):
@ -30,12 +33,26 @@ class Identity(models.Model):
Represents both local and remote Fediverse identities (actors)
"""
# The handle includes the domain!
handle = models.CharField(max_length=500, unique=True)
# The Actor URI is essentially also a PK - we keep the default numeric
# 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)
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)
inbox_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
)
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)
public_key = models.TextField(null=True, blank=True)
@ -62,36 +76,37 @@ class Identity(models.Model):
class Meta:
verbose_name_plural = "identities"
unique_together = [("username", "domain")]
@classmethod
def by_handle(cls, handle, create=True):
def by_handle(cls, handle, fetch=False, local=False):
if handle.startswith("@"):
raise ValueError("Handle must not start with @")
if "@" not in handle:
raise ValueError("Handle must contain domain")
username, domain = handle.split("@")
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:
if create:
if fetch and not local:
return cls.objects.create(handle=handle, local=False)
return None
@classmethod
def by_actor_uri(cls, uri):
def by_actor_uri(cls, uri, create=False):
try:
cls.objects.filter(actor_uri=uri)
return cls.objects.get(actor_uri=uri)
except cls.DoesNotExist:
if create:
return cls.objects.create(actor_uri=uri, local=False)
return None
@property
def short_handle(self):
if self.handle.endswith("@" + settings.DEFAULT_DOMAIN):
return self.handle.split("@", 1)[0]
return self.handle
@property
def domain(self):
return self.handle.split("@", 1)[1]
def handle(self):
return f"{self.username}@{self.domain_id}"
@property
def data_age(self) -> float:
@ -105,6 +120,8 @@ class Identity(models.Model):
return (timezone.now() - self.fetched).total_seconds()
def generate_keypair(self):
if not self.local:
raise ValueError("Cannot generate keypair for remote user")
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
@ -120,44 +137,39 @@ class Identity(models.Model):
)
self.save()
async def fetch_details(self):
if self.local:
raise ValueError("Cannot fetch local identities")
self.actor_uri = None
self.inbox_uri = None
self.profile_uri = None
# Go knock on webfinger and see what their address is
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:
@classmethod
async def fetch_webfinger(cls, handle: str) -> Tuple[Optional[str], Optional[str]]:
"""
Given a username@domain handle, returns a tuple of
(actor uri, canonical handle) or None, None if it does not resolve.
"""
domain = handle.split("@")[1]
async with httpx.AsyncClient() as client:
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"},
follow_redirects=True,
)
if response.status_code >= 400:
return False
return None, None
data = response.json()
if data["subject"].startswith("acct:"):
data["subject"] = data["subject"][5:]
for link in data["links"]:
if (
link.get("type") == "application/activity+json"
and link.get("rel") == "self"
):
self.actor_uri = link["href"]
elif (
link.get("type") == "text/html"
and link.get("rel") == "http://webfinger.net/rel/profile-page"
):
self.profile_uri = link["href"]
return True
return link["href"], data["subject"]
return None, None
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:
response = await client.get(
self.actor_uri,
@ -166,29 +178,48 @@ class Identity(models.Model):
)
if response.status_code >= 400:
return False
document = canonicalise(response.json())
document = canonicalise(response.json(), include_security=True)
self.name = document.get("name")
self.profile_uri = document.get("url")
self.inbox_uri = document.get("inbox")
self.outbox_uri = document.get("outbox")
self.summary = document.get("summary")
self.username = document.get("preferredUsername")
self.manually_approves_followers = document.get(
"as:manuallyApprovesFollowers"
)
self.public_key = document.get("publicKey", {}).get("publicKeyPem")
self.icon_uri = document.get("icon", {}).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
def sign(self, cleartext: str) -> str:
if not self.private_key:
raise ValueError("Cannot sign - no private key")
private_key = serialization.load_pem_private_key(
self.private_key,
self.private_key.encode("ascii"),
password=None,
)
return base64.b64encode(
private_key.sign(
cleartext,
cleartext.encode("utf8"),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
@ -199,12 +230,13 @@ class Identity(models.Model):
def verify_signature(self, crypttext: str, cleartext: str) -> bool:
if not self.public_key:
raise ValueError("Cannot verify - no private key")
public_key = serialization.load_pem_public_key(self.public_key)
raise ValueError("Cannot verify - no public key")
public_key = serialization.load_pem_public_key(self.public_key.encode("ascii"))
print("sig??", crypttext, cleartext)
try:
public_key.verify(
crypttext,
cleartext,
crypttext.encode("utf8"),
cleartext.encode("utf8"),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
@ -250,10 +282,18 @@ class Identity(models.Model):
pass
def __str__(self):
return self.name or self.handle
return self.handle or self.actor_uri
class urls(urlman.Urls):
view = "/@{self.short_handle}/"
view = "/@{self.username}@{self.domain_id}/"
view_short = "/@{self.username}/"
actor = "{view}actor/"
inbox = "{actor}inbox/"
outbox = "{actor}outbox/"
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 users.models import Identity
from users.models import Domain, Identity
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.
Domain-sensitive, so it will understand short handles on alternate domains.
"""
# TODO: Domain sensitivity
if "@" not in handle:
handle += "@" + settings.DEFAULT_DOMAIN
if local:
return get_object_or_404(Identity.objects.filter(local=True), handle=handle)
if "HTTP_HOST" not in request.META:
raise Http404("No hostname available")
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:
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 string
from cryptography.hazmat.primitives import hashes
from asgiref.sync import async_to_sync
from django import forms
from django.conf import settings
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.ld import canonicalise
from core.signatures import HttpSignature
from miniq.models import Task
from users.models import Identity
from users.models import Domain, Identity
from users.shortcuts import by_handle_or_404
@ -24,7 +24,7 @@ class ViewIdentity(TemplateView):
template_name = "identity/view.html"
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]
if identity.data_age > settings.IDENTITY_MAX_AGE:
Task.submit("identity_fetch", identity.handle)
@ -65,36 +65,49 @@ class CreateIdentity(FormView):
template_name = "identity/create.html"
class form_class(forms.Form):
handle = forms.CharField()
username = forms.CharField()
name = forms.CharField()
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 @
value = self.cleaned_data["handle"].lstrip("@")
value = self.cleaned_data["username"].lstrip("@")
# Validate it's all ascii characters
for character in value:
if character not in string.ascii_letters + string.digits + "_-":
raise forms.ValidationError(
"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
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):
username = form.cleaned_data["username"]
domain = form.cleaned_data["domain"]
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"],
local=True,
)
@ -110,23 +123,28 @@ class Actor(View):
def get(self, request, handle):
identity = by_handle_or_404(self.request, handle)
return JsonResponse(
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
"type": "Person",
"preferredUsername": identity.short_handle,
"inbox": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.inbox}",
"publicKey": {
"id": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}#main-key",
"owner": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
"publicKeyPem": identity.public_key,
},
}
)
response = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"id": identity.urls.actor.full(),
"type": "Person",
"inbox": identity.urls.inbox.full(),
"preferredUsername": identity.username,
"publicKey": {
"id": identity.urls.actor.full() + "#main-key",
"owner": identity.urls.actor.full(),
"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")
@ -136,48 +154,45 @@ class Inbox(View):
"""
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:
print("No signature")
return HttpResponseBadRequest()
# Split apart signature
signature_details = {}
for item in request.META["HTTP_SIGNATURE"].split(","):
name, value = item.split("=", 1)
value = value.strip('"')
signature_details[name] = value
signature_details = HttpSignature.parse_signature(
request.META["HTTP_SIGNATURE"]
)
# Reject unknown algorithms
if signature_details["algorithm"] != "rsa-sha256":
print("Unknown algorithm")
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
headers = {}
for header_name in signature_details["headers"].split():
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())
headers_string = HttpSignature.headers_from_request(
request, signature_details["headers"]
)
# Load the LD
document = canonicalise(json.loads(request.body))
print(signature_details)
print(headers_string)
print(document)
# Find the Identity by the actor on the incoming item
identity = Identity.by_actor_uri(document["actor"])
if not identity.verify_signature(signature_details["signature"], signed_string):
identity = Identity.by_actor_uri(document["actor"], create=True)
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")
return HttpResponseBadRequest()
# return HttpResponseBadRequest("Bad signature")
return JsonResponse({"status": "OK"})
@ -190,24 +205,24 @@ class Webfinger(View):
resource = request.GET.get("resource")
if not resource.startswith("acct:"):
raise Http404("Not an account resource")
handle = resource[5:]
handle = resource[5:].replace("testfedi", "feditest")
identity = by_handle_or_404(request, handle)
return JsonResponse(
{
"subject": f"acct:{identity.handle}",
"aliases": [
f"https://{settings.DEFAULT_DOMAIN}/@{identity.short_handle}",
identity.urls.view_short.full(),
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.view}",
"href": identity.urls.view_short.full(),
},
{
"rel": "self",
"type": "application/activity+json",
"href": f"https://{settings.DEFAULT_DOMAIN}{identity.urls.actor}",
"href": identity.urls.actor.full(),
},
],
}