This repository has been archived on 2023-09-24. You can view files and clone it, but cannot push or open issues or pull requests.
takahe/users/views/identity.py

274 lines
9.6 KiB
Python
Raw Normal View History

2022-11-06 07:07:38 +01:00
import json
2022-11-06 03:10:39 +01:00
import string
from asgiref.sync import async_to_sync
2022-11-05 21:17:27 +01:00
from django import forms
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse
2022-11-05 21:17:27 +01:00
from django.shortcuts import redirect
from django.utils import timezone
2022-11-05 21:17:27 +01:00
from django.utils.decorators import method_decorator
from django.utils.http import parse_http_date
2022-11-06 00:51:54 +01:00
from django.views.decorators.csrf import csrf_exempt
2022-11-05 21:17:27 +01:00
from django.views.generic import FormView, TemplateView, View
from core.forms import FormHelper
2022-11-06 07:07:38 +01:00
from core.ld import canonicalise
from core.signatures import HttpSignature
from users.decorators import identity_required
from users.models import Domain, Follow, Identity, IdentityStates, InboxMessage
2022-11-05 21:17:27 +01:00
from users.shortcuts import by_handle_or_404
class HttpResponseUnauthorized(HttpResponse):
status_code = 401
2022-11-05 21:17:27 +01:00
class ViewIdentity(TemplateView):
template_name = "identity/view.html"
def get_context_data(self, handle):
identity = by_handle_or_404(
self.request,
handle,
local=False,
fetch=True,
)
2022-11-12 07:04:43 +01:00
posts = identity.posts.all()[:100]
2022-11-06 05:49:25 +01:00
if identity.data_age > settings.IDENTITY_MAX_AGE:
identity.transition_perform(IdentityStates.outdated)
2022-11-05 21:17:27 +01:00
return {
"identity": identity,
2022-11-12 07:04:43 +01:00
"posts": posts,
"follow": Follow.maybe_get(self.request.identity, identity)
if self.request.identity
else None,
2022-11-05 21:17:27 +01:00
}
@method_decorator(identity_required, name="dispatch")
class ActionIdentity(View):
def post(self, request, handle):
identity = by_handle_or_404(self.request, handle, local=False)
# See what action we should perform
action = self.request.POST["action"]
if action == "follow":
existing_follow = Follow.maybe_get(self.request.identity, identity)
if not existing_follow:
Follow.create_local(self.request.identity, identity)
else:
raise ValueError(f"Cannot handle identity action {action}")
return redirect(identity.urls.view)
2022-11-05 21:17:27 +01:00
@method_decorator(login_required, name="dispatch")
class SelectIdentity(TemplateView):
template_name = "identity/select.html"
def get_context_data(self):
return {
"identities": Identity.objects.filter(users__pk=self.request.user.pk),
}
2022-11-06 03:10:39 +01:00
@method_decorator(login_required, name="dispatch")
class ActivateIdentity(View):
def get(self, request, handle):
identity = by_handle_or_404(request, handle)
if not identity.users.filter(pk=request.user.pk).exists():
raise Http404()
request.session["identity_id"] = identity.id
# Get next URL, not allowing offsite links
next = request.GET.get("next") or "/"
if ":" in next:
next = "/"
return redirect("/")
2022-11-05 21:17:27 +01:00
@method_decorator(login_required, name="dispatch")
class CreateIdentity(FormView):
template_name = "identity/create.html"
class form_class(forms.Form):
username = forms.CharField()
2022-11-05 21:17:27 +01:00
name = forms.CharField()
helper = FormHelper(submit_text="Create")
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):
2022-11-05 21:17:27 +01:00
# Remove any leading @
value = self.cleaned_data["username"].lstrip("@")
2022-11-06 03:10:39 +01:00
# 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."
)
2022-11-05 21:17:27 +01:00
return value
def clean(self):
# Check for existing users
username = self.cleaned_data.get("username")
domain = self.cleaned_data.get("domain")
if (
username
and domain
and 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())
2022-11-05 21:17:27 +01:00
def form_valid(self, form):
username = form.cleaned_data["username"]
domain = form.cleaned_data["domain"]
domain_instance = Domain.get_domain(domain)
2022-11-05 21:17:27 +01:00
new_identity = Identity.objects.create(
actor_uri=f"https://{domain_instance.uri_domain}/@{username}@{domain}/actor/",
username=username,
domain_id=domain,
2022-11-05 21:17:27 +01:00
name=form.cleaned_data["name"],
local=True,
)
new_identity.users.add(self.request.user)
new_identity.generate_keypair()
return redirect(new_identity.urls.view)
class Actor(View):
"""
Returns the AP Actor object
"""
def get(self, request, handle):
identity = by_handle_or_404(self.request, handle)
response = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
],
"id": identity.actor_uri,
"type": "Person",
"inbox": identity.actor_uri + "inbox/",
"preferredUsername": identity.username,
"publicKey": {
"id": identity.key_id,
"owner": identity.actor_uri,
"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))
2022-11-05 21:17:27 +01:00
2022-11-06 00:51:54 +01:00
@method_decorator(csrf_exempt, name="dispatch")
class Inbox(View):
"""
AP Inbox endpoint
"""
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("Wrong digest")
return HttpResponseBadRequest("Digest is incorrect")
# Verify date header
if "HTTP_DATE" in request.META:
header_date = parse_http_date(request.META["HTTP_DATE"])
if abs(timezone.now().timestamp() - header_date) > 60:
print(
f"Date mismatch - they sent {header_date}, now is {timezone.now().timestamp()}"
)
return HttpResponseBadRequest("Date is too far away")
# Get the signature details
2022-11-06 07:07:38 +01:00
if "HTTP_SIGNATURE" not in request.META:
print("No signature")
return HttpResponseBadRequest("No signature present")
signature_details = HttpSignature.parse_signature(
request.META["HTTP_SIGNATURE"]
)
2022-11-06 07:07:38 +01:00
# Reject unknown algorithms
if signature_details["algorithm"] != "rsa-sha256":
print("Unknown sig algo")
return HttpResponseBadRequest("Unknown signature algorithm")
2022-11-06 07:07:38 +01:00
# Create the signature payload
headers_string = HttpSignature.headers_from_request(
request, signature_details["headers"]
)
2022-11-06 07:07:38 +01:00
# Load the LD
document = canonicalise(json.loads(request.body))
# Find the Identity by the actor on the incoming item
# This ensures that the signature used for the headers matches the actor
# described in the payload.
2022-11-12 06:02:43 +01:00
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 get actor")
return HttpResponseBadRequest("Cannot retrieve actor")
if not identity.verify_signature(
signature_details["signature"], headers_string
):
2022-11-12 07:04:43 +01:00
print("Bad signature!")
print(document)
return HttpResponseUnauthorized("Bad signature")
# Hand off the item to be processed by the queue
2022-11-12 07:04:43 +01:00
InboxMessage.objects.create(message=document)
return HttpResponse(status=202)
2022-11-06 00:51:54 +01:00
2022-11-05 21:17:27 +01:00
class Webfinger(View):
"""
Services webfinger requests
"""
def get(self, request):
resource = request.GET.get("resource")
if not resource.startswith("acct:"):
raise Http404("Not an account resource")
handle = resource[5:].replace("testfedi", "feditest")
2022-11-05 21:17:27 +01:00
identity = by_handle_or_404(request, handle)
return JsonResponse(
{
"subject": f"acct:{identity.handle}",
"aliases": [
identity.urls.view_short.full(),
2022-11-05 21:17:27 +01:00
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": identity.urls.view_short.full(),
2022-11-05 21:17:27 +01:00
},
{
"rel": "self",
"type": "application/activity+json",
"href": identity.actor_uri,
2022-11-05 21:17:27 +01:00
},
],
}
)