diff options
-rw-r--r-- | core/context.py | 4 | ||||
-rw-r--r-- | core/ld.py | 11 | ||||
-rw-r--r-- | core/signatures.py | 48 | ||||
-rw-r--r-- | miniq/migrations/0001_initial.py | 9 | ||||
-rw-r--r-- | miniq/views.py | 7 | ||||
-rw-r--r-- | static/css/style.css | 3 | ||||
-rw-r--r-- | statuses/migrations/0001_initial.py | 2 | ||||
-rw-r--r-- | statuses/models/status.py | 2 | ||||
-rw-r--r-- | takahe/settings.py | 3 | ||||
-rw-r--r-- | templates/base.html | 8 | ||||
-rw-r--r-- | templates/identity/select.html | 2 | ||||
-rw-r--r-- | templates/identity/view.html | 2 | ||||
-rw-r--r-- | templates/statuses/_status.html | 2 | ||||
-rw-r--r-- | users/admin.py | 7 | ||||
-rw-r--r-- | users/decorators.py | 17 | ||||
-rw-r--r-- | users/middleware.py | 24 | ||||
-rw-r--r-- | users/migrations/0001_initial.py | 93 | ||||
-rw-r--r-- | users/models/__init__.py | 2 | ||||
-rw-r--r-- | users/models/block.py | 30 | ||||
-rw-r--r-- | users/models/domain.py | 83 | ||||
-rw-r--r-- | users/models/follow.py | 2 | ||||
-rw-r--r-- | users/models/identity.py | 144 | ||||
-rw-r--r-- | users/shortcuts.py | 27 | ||||
-rw-r--r-- | users/views/identity.py | 153 |
24 files changed, 517 insertions, 168 deletions
diff --git a/core/context.py b/core/context.py index 38a268c..026ac11 100644 --- a/core/context.py +++ b/core/context.py @@ -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}, + } @@ -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" + ), ) diff --git a/core/signatures.py b/core/signatures.py new file mode 100644 index 0000000..bcacb68 --- /dev/null +++ b/core/signatures.py @@ -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 diff --git a/miniq/migrations/0001_initial.py b/miniq/migrations/0001_initial.py index 6775ff3..32c5d53 100644 --- a/miniq/migrations/0001_initial.py +++ b/miniq/migrations/0001_initial.py @@ -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)), diff --git a/miniq/views.py b/miniq/views.py index 12da7cd..21275f8 100644 --- a/miniq/views.py +++ b/miniq/views.py @@ -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() diff --git a/static/css/style.css b/static/css/style.css index 7a3b20a..511d301 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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); diff --git a/statuses/migrations/0001_initial.py b/statuses/migrations/0001_initial.py index 58a7d29..933c526 100644 --- a/statuses/migrations/0001_initial.py +++ b/statuses/migrations/0001_initial.py @@ -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 diff --git a/statuses/models/status.py b/statuses/models/status.py index ac40806..bfc8eb9 100644 --- a/statuses/models/status.py +++ b/statuses/models/status.py @@ -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}/" diff --git a/takahe/settings.py b/takahe/settings.py index 26fd705..c3c8d38 100644 --- a/takahe/settings.py +++ b/takahe/settings.py @@ -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" diff --git a/templates/base.html b/templates/base.html index af2887f..2ff0f15 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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 %} diff --git a/templates/identity/select.html b/templates/identity/select.html index dae1ca1..ea4065c 100644 --- a/templates/identity/select.html +++ b/templates/identity/select.html @@ -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> diff --git a/templates/identity/view.html b/templates/identity/view.html index 2a82478..ffb76db 100644 --- a/templates/identity/view.html +++ b/templates/identity/view.html @@ -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 %} diff --git a/templates/statuses/_status.html b/templates/statuses/_status.html index b89909a..b501abc 100644 --- a/templates/statuses/_status.html +++ b/templates/statuses/_status.html @@ -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> diff --git a/users/admin.py b/users/admin.py index e5db9d1..5672876 100644 --- a/users/admin.py +++ b/users/admin.py @@ -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) diff --git a/users/decorators.py b/users/decorators.py index 77d633a..d373692 100644 --- a/users/decorators.py +++ b/users/decorators.py @@ -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 diff --git a/users/middleware.py b/users/middleware.py new file mode 100644 index 0000000..aa22178 --- /dev/null +++ b/users/middleware.py @@ -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 diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index 5f9eacb..364daaa 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -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 @@ -48,6 +48,30 @@ class Migration(migrations.Migration): }, ), 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,11 +164,6 @@ 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)), @@ -142,12 +171,25 @@ class Migration(migrations.Migration): ("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( related_name="identities", to=settings.AUTH_USER_MODEL ), ), ], + 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", + ), + ), + ], + ), ] diff --git a/users/models/__init__.py b/users/models/__init__.py index ce69d1d..e1877bc 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -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 diff --git a/users/models/block.py b/users/models/block.py new file mode 100644 index 0000000..d312363 --- /dev/null +++ b/users/models/block.py @@ -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) diff --git a/users/models/domain.py b/users/models/domain.py new file mode 100644 index 0000000..f503b89 --- /dev/null +++ b/users/models/domain.py @@ -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 diff --git a/users/models/follow.py b/users/models/follow.py index e134f28..7287900 100644 --- a/users/models/follow.py +++ b/users/models/follow.py @@ -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( diff --git a/users/models/identity.py b/users/models/identity.py index 3aa4545..b5f9897 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -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 diff --git a/users/shortcuts.py b/users/shortcuts.py index 0e00404..167f178 100644 --- a/users/shortcuts.py +++ b/users/shortcuts.py @@ -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 "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: + username, domain = handle.split("@", 1) if local: - return get_object_or_404(Identity.objects.filter(local=True), handle=handle) + return get_object_or_404( + Identity.objects.filter(local=True), + username=username, + domain_id=domain, + ) else: - return get_object_or_404(Identity, handle=handle) + return get_object_or_404( + Identity, + username=username, + domain_id=domain, + ) diff --git a/users/views/identity.py b/users/views/identity.py index d8f241f..1beef2a 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -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(), }, ], } |